157 lines
6.4 KiB
JavaScript
157 lines
6.4 KiB
JavaScript
// 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');
|
|
});
|
|
});
|