Files
vyndr/tests/unit/providerGateway.test.js
T
builtbykev 2d413cfe1e Quota guard: close the silent odds-api drain + reserve floor for MLB
Diagnosis (why 500/500 went unpaged): the only regular odds-api burner was
futuresService, which called axios DIRECTLY — bypassing the gateway, so it
never hit recordCall (the ONE place the WARN/BLOCK pager fires) and never
respected the 95% block. It only syncFromHeaders, which updated the counter's
number SILENTLY. oddsService (which does go through the gateway) only touches
odds-api when PropLine fails, so recordCall for odds-api effectively never ran.
Result: the counter could reach 100% with neither pager firing.

Fixes (a silent drain is now impossible, not just guarded):
- futuresService routes through gateway.fetch('odds-api', …) → counted, blocked
  at 95%, and reserve-gated. Closes the raw-axios bypass.
- Reserve floor in the gateway: a DISCRETIONARY call (futures/soccer) passes
  reserve=ODDS_API_RESERVE (default 50) and is refused while remaining <= reserve.
  The ESSENTIAL MLB prop-backup passes no reserve and may spend to the 95% block.
  → a futures/soccer drain can NEVER starve MLB's backup path.
- quotaTracker.syncFromHeaders (the AUTHORITATIVE number) now fires the same
  once-per-period WARN/BLOCK alert on a crossing — extracted fireThresholdAlert
  shared with recordCall. The header-only drain now pages.
- POST /api/internal/quota/test-alert (internal-key) test-fires the pager
  end-to-end so ntfy delivery is verifiable on demand.

Also (reality-corrected cadence): WNBA restored to the full grid. 2026-07-15
had two AFTERNOON WNBA games finished before the 22 UTC slot — 14 UTC (10am ET)
is the only slot early enough for a 1pm ET game's props, and on PropLine the
extra slots cost a rounding error. Soccer stays the only trimmed sport (the
real odds-api discipline). Assumption corrected by observed data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:07:14 -04:00

193 lines
8.3 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),
getQuotaStatus: jest.fn(async (providerId) => {
const s = state.get(providerId) || { allowed: true, used: 0, limit: 500 };
const remaining = s.remaining != null ? s.remaining : (s.limit - s.used);
return { provider: providerId, allowed: s.allowed, used: s.used, limit: s.limit, remaining, degraded: !!s.degraded };
}),
__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');
});
});
describe('gateway.fetch — reserve floor (quota guard)', () => {
test('a DISCRETIONARY call (reserve>0) is refused while remaining <= reserve', async () => {
// 470 used of 500 → 30 remaining, at or below a 50-credit reserve.
tracker.__setStatus('odds-api', true, { used: 470, remaining: 30 });
const cb = jest.fn(async () => ({ data: 'x' }));
await expect(
gateway.fetch('odds-api', cb, { capability: 'futures', sport: 'mlb', reserve: 50 }),
).rejects.toMatchObject({ code: 'QUOTA_EXHAUSTED' });
expect(cb).not.toHaveBeenCalled(); // never spent the reserved credits
expect(tracker.recordCall).not.toHaveBeenCalled();
});
test('an ESSENTIAL call (no reserve) still uses the same remaining credits', async () => {
// Same 30 remaining, but the MLB prop-backup path passes no reserve — it may
// spend down to the normal 95% block. This is the MLB-never-starved guarantee.
tracker.__setStatus('odds-api', true, { used: 470, remaining: 30 });
const cb = jest.fn(async () => ({ data: 'ok' }));
const out = await gateway.fetch('odds-api', cb, { capability: 'odds', sport: 'mlb' });
expect(out).toEqual({ data: 'ok' });
expect(cb).toHaveBeenCalledTimes(1);
});
test('a discretionary call PROCEEDS when remaining is above the reserve', async () => {
tracker.__setStatus('odds-api', true, { used: 300, remaining: 200 });
const cb = jest.fn(async () => ({ data: 'ok' }));
const out = await gateway.fetch('odds-api', cb, { capability: 'futures', sport: 'mlb', reserve: 50 });
expect(out).toEqual({ data: 'ok' });
expect(cb).toHaveBeenCalledTimes(1);
});
});