Sessions 29-30: Content templates + PropLine 3-key adapter + MLB Stats API + ESPN summary (1694 tests)

This commit is contained in:
Kev
2026-06-14 22:29:01 -04:00
parent 927c4a5c65
commit a3351e2135
14 changed files with 1091 additions and 27 deletions
+148
View File
@@ -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();
});
});