Sessions 29-30: Content templates + PropLine 3-key adapter + MLB Stats API + ESPN summary (1694 tests)
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user