183 lines
5.1 KiB
JavaScript
183 lines
5.1 KiB
JavaScript
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}$/);
|
|
});
|
|
});
|
|
});
|