// 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(); }); });