149 lines
5.5 KiB
JavaScript
149 lines
5.5 KiB
JavaScript
// Unit: PropLine adapter (Session 30). 3-key rotation + normalization.
|
|
|
|
const mockAxiosGet = jest.fn();
|
|
jest.mock('axios', () => ({ get: (...a) => mockAxiosGet(...a) }));
|
|
|
|
// Gateway is a pass-through in tests (quota allowed).
|
|
jest.mock('../../src/services/providerGateway', () => ({
|
|
fetch: jest.fn(async (_id, cb) => cb('propline')),
|
|
}));
|
|
|
|
const mockRedisStore = {};
|
|
jest.mock('../../src/utils/redis', () => ({
|
|
getRedisClient: () => ({
|
|
get: async (k) => (k in mockRedisStore ? String(mockRedisStore[k]) : null),
|
|
incr: async (k) => { mockRedisStore[k] = (mockRedisStore[k] || 0) + 1; return mockRedisStore[k]; },
|
|
expire: async () => 1,
|
|
}),
|
|
isDegraded: () => false,
|
|
}));
|
|
|
|
const adapter = require('../../src/services/adapters/proplineAdapter');
|
|
|
|
const KEYS = { PROPLINE_API_KEY_1: 'k1', PROPLINE_API_KEY_2: 'k2', PROPLINE_API_KEY_3: 'k3' };
|
|
|
|
beforeEach(() => {
|
|
mockAxiosGet.mockReset();
|
|
for (const k of Object.keys(mockRedisStore)) delete mockRedisStore[k];
|
|
for (const k of Object.keys(adapter.__internals.memUsage)) delete adapter.__internals.memUsage[k];
|
|
Object.assign(process.env, KEYS);
|
|
});
|
|
afterAll(() => {
|
|
delete process.env.PROPLINE_API_KEY_1;
|
|
delete process.env.PROPLINE_API_KEY_2;
|
|
delete process.env.PROPLINE_API_KEY_3;
|
|
});
|
|
|
|
const SAMPLE = [{
|
|
id: '43866',
|
|
sport_key: 'baseball_mlb',
|
|
home_team: 'Cincinnati Reds',
|
|
away_team: 'Arizona Diamondbacks',
|
|
commence_time: '2026-06-14T02:05:00Z',
|
|
bookmakers: [{
|
|
key: 'betmgm', title: 'BetMGM', last_update: '2026-06-14T00:45:30Z',
|
|
markets: [{
|
|
key: 'batter_hits', last_update: '2026-06-13T15:12:46Z',
|
|
outcomes: [
|
|
{ name: 'Over', description: 'Braxton Fulford', price: -200, point: 0.5 },
|
|
{ name: 'Under', description: 'Braxton Fulford', price: 160, point: 0.5 },
|
|
],
|
|
}],
|
|
}],
|
|
}];
|
|
|
|
describe('proplineAdapter — config + URL', () => {
|
|
test('hasKeys true when any key set', () => {
|
|
expect(adapter.hasKeys()).toBe(true);
|
|
});
|
|
|
|
test('builds the odds URL with apiKey query param + markets', async () => {
|
|
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
|
await adapter.fetchRaw('mlb');
|
|
const [url, opts] = mockAxiosGet.mock.calls[0];
|
|
expect(url).toBe('https://api.prop-line.com/v1/sports/baseball_mlb/odds');
|
|
expect(opts.params.apiKey).toMatch(/^k[123]$/);
|
|
expect(opts.params.markets).toContain('batter_hits');
|
|
});
|
|
|
|
test('unsupported sport → null without calling axios', async () => {
|
|
expect(await adapter.fetchRaw('cricket')).toBeNull();
|
|
expect(mockAxiosGet).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('proplineAdapter — normalization (Odds-API-compatible)', () => {
|
|
test('getProps normalizes into VYNDR prop shape (MLB market mapped)', async () => {
|
|
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
|
const out = await adapter.getProps('mlb');
|
|
expect(out.source).toBe('propline');
|
|
expect(out.props).toHaveLength(1);
|
|
const p = out.props[0];
|
|
expect(p.player).toBe('Braxton Fulford');
|
|
expect(p.stat_type).toBe('hits'); // batter_hits → hits via MARKET_MAP
|
|
expect(p.line).toBe(0.5);
|
|
expect(p.over_odds).toBe(-200);
|
|
expect(p.under_odds).toBe(160);
|
|
expect(p.book).toBe('betmgm');
|
|
});
|
|
|
|
test('tolerates { data: [...] } wrapper', async () => {
|
|
mockAxiosGet.mockResolvedValue({ data: { data: SAMPLE } });
|
|
const raw = await adapter.fetchRaw('mlb');
|
|
expect(raw).toHaveLength(1);
|
|
});
|
|
|
|
test('error → getProps returns null (caller falls back)', async () => {
|
|
mockAxiosGet.mockRejectedValue(new Error('upstream 500'));
|
|
expect(await adapter.getProps('mlb')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('proplineAdapter — 3-key rotation', () => {
|
|
test('picks the least-used key', async () => {
|
|
const { incrUsage, usageKey } = adapter.__internals;
|
|
// Make key 0 heavily used, key 1 lightly, key 2 medium.
|
|
mockRedisStore[usageKey(0)] = 500;
|
|
mockRedisStore[usageKey(2)] = 100;
|
|
const picked = await adapter.pickKey(adapter.__internals.getKeys());
|
|
expect(picked.index).toBe(1); // unused → most remaining
|
|
});
|
|
|
|
test('rotates OFF a key once it crosses the 900 threshold', async () => {
|
|
const { usageKey } = adapter.__internals;
|
|
mockRedisStore[usageKey(0)] = 950; // over threshold
|
|
mockRedisStore[usageKey(1)] = 950; // over threshold
|
|
mockRedisStore[usageKey(2)] = 10; // healthy
|
|
const picked = await adapter.pickKey(adapter.__internals.getKeys());
|
|
expect(picked.index).toBe(2);
|
|
});
|
|
|
|
test('all keys exhausted (>=1000) → pickKey null, fetchRaw null', async () => {
|
|
const { usageKey } = adapter.__internals;
|
|
mockRedisStore[usageKey(0)] = 1000;
|
|
mockRedisStore[usageKey(1)] = 1000;
|
|
mockRedisStore[usageKey(2)] = 1000;
|
|
expect(await adapter.pickKey(adapter.__internals.getKeys())).toBeNull();
|
|
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
|
expect(await adapter.fetchRaw('mlb')).toBeNull();
|
|
expect(mockAxiosGet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('increments usage on a successful call', async () => {
|
|
mockAxiosGet.mockResolvedValue({ data: SAMPLE });
|
|
await adapter.fetchRaw('mlb');
|
|
const total = Object.entries(mockRedisStore)
|
|
.filter(([k]) => k.startsWith('propline:usage:'))
|
|
.reduce((s, [, v]) => s + v, 0);
|
|
expect(total).toBe(1);
|
|
});
|
|
|
|
test('no keys configured → fetchRaw null', async () => {
|
|
delete process.env.PROPLINE_API_KEY_1;
|
|
delete process.env.PROPLINE_API_KEY_2;
|
|
delete process.env.PROPLINE_API_KEY_3;
|
|
expect(adapter.hasKeys()).toBe(false);
|
|
expect(await adapter.fetchRaw('mlb')).toBeNull();
|
|
});
|
|
});
|