feat: Feature 1.1 — Odds API integration complete, 28 tests passing
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock Redis before importing app
|
||||
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 env
|
||||
process.env.ODDS_API_KEY = 'test-key';
|
||||
|
||||
const app = require('../../src/app');
|
||||
|
||||
const MOCK_EVENTS = [
|
||||
{
|
||||
id: 'game-1',
|
||||
sport_key: 'basketball_nba',
|
||||
home_team: 'Denver Nuggets',
|
||||
away_team: 'Los Angeles Lakers',
|
||||
commence_time: '2026-03-21T19:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_ODDS_RESPONSE = {
|
||||
...MOCK_EVENTS[0],
|
||||
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 },
|
||||
{ name: 'Over', description: 'LeBron James', price: -115, point: 25.5 },
|
||||
{ name: 'Under', description: 'LeBron James', price: -105, point: 25.5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'player_rebounds',
|
||||
last_update: '2026-03-21T14:28:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -130, point: 12.5 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: 110, point: 12.5 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
title: 'FanDuel',
|
||||
markets: [
|
||||
{
|
||||
key: 'player_points',
|
||||
last_update: '2026-03-21T14:30:00Z',
|
||||
outcomes: [
|
||||
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
|
||||
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const API_HEADERS = {
|
||||
'x-requests-remaining': '488',
|
||||
'x-requests-used': '12',
|
||||
'x-requests-last': '1',
|
||||
};
|
||||
|
||||
function setupMockApi() {
|
||||
axios.get
|
||||
.mockResolvedValueOnce({ data: MOCK_EVENTS, headers: API_HEADERS })
|
||||
.mockResolvedValueOnce({ data: MOCK_ODDS_RESPONSE, headers: API_HEADERS });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockRedis.hset.mockResolvedValue(1);
|
||||
mockRedis.hgetall.mockResolvedValue({});
|
||||
mockRedis.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
describe('GET /api/odds/nba', () => {
|
||||
it('returns full response with correct shape on live fetch', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.sport).toBe('nba');
|
||||
expect(res.body.source).toBe('live');
|
||||
expect(res.body.updated_at).toBeDefined();
|
||||
expect(res.body.quota_remaining).toBe(488);
|
||||
expect(Array.isArray(res.body.props)).toBe(true);
|
||||
expect(res.body.props.length).toBeGreaterThan(0);
|
||||
|
||||
// Check grouped structure
|
||||
const jokicPoints = res.body.props.find(
|
||||
(p) => p.player === 'Nikola Jokic' && p.stat_type === 'points'
|
||||
);
|
||||
expect(jokicPoints).toBeDefined();
|
||||
expect(jokicPoints.home_team).toBe('DEN');
|
||||
expect(jokicPoints.away_team).toBe('LAL');
|
||||
expect(jokicPoints.lines.length).toBe(2); // draftkings + fanduel
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('book');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('line');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('over_odds');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('under_odds');
|
||||
expect(jokicPoints.lines[0]).toHaveProperty('fetched_at');
|
||||
});
|
||||
|
||||
it('serves cached data on second request within 15 minutes', async () => {
|
||||
const cachedData = {
|
||||
updated_at: '2026-03-21T14:00:00Z',
|
||||
props: [
|
||||
{
|
||||
player: 'Nikola Jokic',
|
||||
home_team: 'DEN',
|
||||
away_team: 'LAL',
|
||||
game_time: '2026-03-21T19:00:00Z',
|
||||
stat_type: 'points',
|
||||
book: 'draftkings',
|
||||
line: 26.5,
|
||||
over_odds: -110,
|
||||
under_odds: -110,
|
||||
fetched_at: '2026-03-21T14:28:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
mockRedis.get.mockResolvedValue(JSON.stringify(cachedData));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.source).toBe('cache');
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters by stat_type', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?stat_type=rebounds').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
expect(prop.stat_type).toBe('rebounds');
|
||||
}
|
||||
});
|
||||
|
||||
it('filters by player (partial match, case-insensitive)', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?player=jokic').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
expect(prop.player.toLowerCase()).toContain('jokic');
|
||||
}
|
||||
});
|
||||
|
||||
it('filters by book', async () => {
|
||||
setupMockApi();
|
||||
|
||||
const res = await request(app).get('/api/odds/nba?book=fanduel').expect(200);
|
||||
|
||||
for (const prop of res.body.props) {
|
||||
for (const line of prop.lines) {
|
||||
expect(line.book).toBe('fanduel');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 for invalid stat_type', async () => {
|
||||
const res = await request(app).get('/api/odds/nba?stat_type=invalid').expect(400);
|
||||
expect(res.body.error).toContain('Invalid stat_type');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid book', async () => {
|
||||
const res = await request(app).get('/api/odds/nba?book=bovada').expect(400);
|
||||
expect(res.body.error).toContain('Invalid book');
|
||||
});
|
||||
|
||||
it('returns stale cache data when API fails', async () => {
|
||||
mockRedis.get
|
||||
.mockResolvedValueOnce(null) // cache miss
|
||||
.mockResolvedValueOnce(JSON.stringify({ // stale fallback
|
||||
updated_at: '2026-03-21T12:00:00Z',
|
||||
props: [{ player: 'Stale Data', home_team: 'DEN', away_team: 'LAL', game_time: '2026-03-21T19:00:00Z', stat_type: 'points', book: 'draftkings', line: 26.5, over_odds: -110, under_odds: -110, fetched_at: '2026-03-21T12:00:00Z' }],
|
||||
}));
|
||||
|
||||
axios.get.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(200);
|
||||
|
||||
expect(res.body.source).toBe('cache');
|
||||
expect(res.headers['x-betonblk-stale']).toBe('true');
|
||||
});
|
||||
|
||||
it('returns 503 when API fails and no cache', async () => {
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
axios.get.mockRejectedValue(new Error('API down'));
|
||||
|
||||
const res = await request(app).get('/api/odds/nba').expect(503);
|
||||
expect(res.body.error).toBe('Odds service unavailable.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/odds/ncaab', () => {
|
||||
it('returns off-season message when NCAAB not in season', async () => {
|
||||
// Mock Date to July (off-season)
|
||||
const realDate = Date;
|
||||
const mockDate = new Date('2026-07-15T12:00:00Z');
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
Date.now = realDate.now;
|
||||
|
||||
const res = await request(app).get('/api/odds/ncaab').expect(200);
|
||||
|
||||
expect(res.body.props).toEqual([]);
|
||||
expect(res.body.message).toContain('off-season');
|
||||
expect(res.body.source).toBe('none');
|
||||
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user