// Provider gateway (Session 20). // // Covers: happy path (callback invoked once), quota-block fallback // (primary blocked → walks fallback chain), full exhaustion // (QuotaExhaustedError), upstream errors propagate without // shifting, and header sync is invoked on success. jest.mock('../../src/services/quotaTracker', () => { // The mock keeps a per-test counter so we can drive different // providers into different quota states without writing to Redis. const state = new Map(); const setStatus = (providerId, allowed, extra = {}) => { state.set(providerId, { allowed, used: extra.used || 0, limit: 500, ...extra }); }; return { recordCall: jest.fn(async (providerId) => { const s = state.get(providerId) || { allowed: true, used: 0, limit: 500 }; if (!s.allowed) return { provider: providerId, allowed: false, reason: s.reason || 'blocked' }; return { provider: providerId, allowed: true, used: s.used + 1, limit: s.limit }; }), rollback: jest.fn(async () => {}), syncFromHeaders: jest.fn(async () => null), __state: state, __setStatus: setStatus, }; }); jest.mock('../../src/config/providers', () => { const PROVIDERS = { 'odds-api': { name: 'The Odds API', capabilities: ['odds'], sports: ['nba'], envKey: 'ODDS_API_KEY', priority: 1 }, 'oddspapi': { name: 'ODDSPAPI', capabilities: ['odds'], sports: ['nba'], envKey: 'ODDSPAPI_KEY', priority: 2 }, 'parlayapi': { name: 'ParlayAPI', capabilities: ['odds'], sports: ['nba'], envKey: 'PARLAYAPI_KEY', priority: 3 }, }; return { PROVIDERS, getProvider: (id) => PROVIDERS[id] || null, getFallbackChain: (capability, sport, excludeId) => Object.entries(PROVIDERS) .filter(([id, cfg]) => id !== excludeId && cfg.capabilities.includes(capability) && (!sport || cfg.sports.includes(sport)) && !!process.env[cfg.envKey], ) .sort((a, b) => a[1].priority - b[1].priority) .map(([id]) => id), listProviderIds: () => Object.keys(PROVIDERS), getConfiguredProviders: () => Object.keys(PROVIDERS).filter((k) => !!process.env[PROVIDERS[k].envKey]), }; }); const tracker = require('../../src/services/quotaTracker'); const gateway = require('../../src/services/providerGateway'); beforeEach(() => { tracker.__state.clear(); tracker.recordCall.mockClear(); tracker.rollback.mockClear(); tracker.syncFromHeaders.mockClear(); process.env.ODDS_API_KEY = 'k1'; process.env.ODDSPAPI_KEY = 'k2'; process.env.PARLAYAPI_KEY = 'k3'; }); describe('gateway.fetch — happy path', () => { test('invokes callback once and returns its result', async () => { const cb = jest.fn(async () => ({ ok: true })); const result = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba' }); expect(result).toEqual({ ok: true }); expect(cb).toHaveBeenCalledTimes(1); expect(cb).toHaveBeenCalledWith('odds-api'); expect(tracker.rollback).not.toHaveBeenCalled(); }); test('invokes syncHeadersFrom on success', async () => { const cb = jest.fn(async () => ({ headers: { 'x-requests-remaining': '100' } })); await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba', syncHeadersFrom: (r) => r.headers, }); expect(tracker.syncFromHeaders).toHaveBeenCalledWith('odds-api', { 'x-requests-remaining': '100' }); }); }); describe('gateway.fetch — quota fallback', () => { test('walks the chain when the primary is blocked', async () => { tracker.__setStatus('odds-api', false); tracker.__setStatus('oddspapi', true); const cb = jest.fn(async (provider) => ({ ok: true, from: provider })); const result = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba' }); expect(result.from).toBe('oddspapi'); expect(cb).toHaveBeenCalledTimes(1); // primary skipped pre-call expect(cb).toHaveBeenCalledWith('oddspapi'); expect(tracker.rollback).toHaveBeenCalledWith('odds-api'); // rolled back the optimistic increment }); test('skips through multiple blocked providers to the next allowed', async () => { tracker.__setStatus('odds-api', false); tracker.__setStatus('oddspapi', false); tracker.__setStatus('parlayapi', true); const cb = jest.fn(async (provider) => ({ from: provider })); const result = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba' }); expect(result.from).toBe('parlayapi'); }); test('honors explicit fallbackProviders over derived chain', async () => { tracker.__setStatus('odds-api', false); tracker.__setStatus('parlayapi', true); const cb = jest.fn(async (provider) => ({ from: provider })); const result = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba', fallbackProviders: ['parlayapi'], // skip oddspapi }); expect(result.from).toBe('parlayapi'); }); }); describe('gateway.fetch — full exhaustion', () => { test('throws QuotaExhaustedError when every provider is blocked', async () => { tracker.__setStatus('odds-api', false); tracker.__setStatus('oddspapi', false); tracker.__setStatus('parlayapi', false); const cb = jest.fn(); await expect(gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba' })) .rejects.toMatchObject({ name: 'QuotaExhaustedError', code: 'QUOTA_EXHAUSTED', statusCode: 503, }); expect(cb).not.toHaveBeenCalled(); }); test('reports the primary and the attempt chain on the error', async () => { tracker.__setStatus('odds-api', false); tracker.__setStatus('oddspapi', false); tracker.__setStatus('parlayapi', false); try { await gateway.fetch('odds-api', jest.fn(), { capability: 'odds', sport: 'nba' }); throw new Error('should have thrown'); } catch (err) { expect(err.primary).toBe('odds-api'); expect(err.attempts.map((a) => a.provider)).toEqual(['odds-api', 'oddspapi', 'parlayapi']); } }); }); describe('gateway.fetch — upstream errors', () => { test('propagates the adapter error without falling over', async () => { const adapterErr = new Error('upstream 502'); const cb = jest.fn(async () => { throw adapterErr; }); await expect(gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'nba' })) .rejects.toBe(adapterErr); // The increment is rolled back so we don't burn quota on a failed call. expect(tracker.rollback).toHaveBeenCalledWith('odds-api'); }); });