Session 20: Provider intelligence — quota tracker, gateway with fallback cascade, admin quota dashboard (1476 tests)

This commit is contained in:
Kev
2026-06-12 00:54:39 -04:00
parent 56392ec8f4
commit 9b10bb4138
17 changed files with 1422 additions and 15 deletions
+39
View File
@@ -14,6 +14,11 @@ jest.mock('../../scripts/tank01-prefetch', () => ({
}));
const tank01Prefetch = require('../../scripts/tank01-prefetch');
jest.mock('../../src/services/quotaTracker', () => ({
getAllQuotaStatuses: jest.fn(),
}));
const quotaTracker = require('../../src/services/quotaTracker');
beforeEach(() => {
jest.resetAllMocks();
process.env.VYNDR_INTERNAL_KEY = 'test-internal-key-9999';
@@ -98,3 +103,37 @@ describe('POST /api/internal/prefetch/tank01', () => {
expect(argv).toContain('--sports=mlb');
});
});
describe('GET /api/internal/quota (Session 20)', () => {
test('rejects without the internal key', async () => {
const app = mountApp();
const res = await request(app).get('/api/internal/quota');
expect(res.status).toBe(401);
expect(quotaTracker.getAllQuotaStatuses).not.toHaveBeenCalled();
});
test('returns the per-provider snapshot when keyed', async () => {
const fakeSnapshot = [
{ provider: 'odds-api', name: 'The Odds API', used: 487, limit: 500, remaining: 13, pct: 0.974, allowed: false, period: '2026-06', quotaType: 'monthly' },
{ provider: 'tank01', name: 'Tank01', used: 30, limit: 1000, remaining: 970, pct: 0.03, allowed: true, period: '2026-06', quotaType: 'monthly' },
];
quotaTracker.getAllQuotaStatuses.mockResolvedValueOnce(fakeSnapshot);
const app = mountApp();
const res = await request(app)
.get('/api/internal/quota')
.set('x-internal-key', 'test-internal-key-9999');
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true, providers: fakeSnapshot });
});
test('returns 500 with the underlying error when the tracker throws', async () => {
quotaTracker.getAllQuotaStatuses.mockRejectedValueOnce(new Error('redis down'));
const app = mountApp();
const res = await request(app)
.get('/api/internal/quota')
.set('x-internal-key', 'test-internal-key-9999');
expect(res.status).toBe(500);
expect(res.body).toEqual({ ok: false, error: 'redis down' });
});
});
+7
View File
@@ -10,6 +10,13 @@ const mockRedis = {
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
// Session 20 — provider gateway pulls cacheGet/cacheSet/isDegraded.
// Degraded mode lets every call through and skips Redis writes, so
// the existing axios-mock assertions stay accurate.
cacheGet: jest.fn(async () => null),
cacheSet: jest.fn(async () => true),
cacheDel: jest.fn(async () => true),
isDegraded: jest.fn(() => true),
}));
// Mock axios
+9
View File
@@ -10,6 +10,15 @@ const mockRedis = {
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
// Session 20 — the provider gateway + quotaTracker pull from
// cacheGet/cacheSet/isDegraded. We surface them as degraded-mode
// no-ops here so the gateway fails OPEN in tests (lets every call
// through without touching Redis), which preserves the legacy
// axios-mock-driven assertions in this file.
cacheGet: jest.fn(async () => null),
cacheSet: jest.fn(async () => true),
cacheDel: jest.fn(async () => true),
isDegraded: jest.fn(() => true),
}));
// Mock axios
+156
View File
@@ -0,0 +1,156 @@
// 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');
});
});
+223
View File
@@ -0,0 +1,223 @@
// Quota tracker (Session 20).
//
// Pins behavior so the gateway + scheduler can rely on it: period
// keys reflect the configured quotaType, recordCall advances the
// counter, syncFromHeaders is the truth-source override, the 80%
// warning fires once per period, and the 95% threshold flips
// `allowed` from true to false. Redis is mocked so the tests don't
// require a live server.
jest.mock('../../src/utils/redis', () => {
const store = new Map();
return {
cacheGet: jest.fn(async (key) => {
if (!store.has(key)) return null;
return store.get(key);
}),
cacheSet: jest.fn(async (key, value) => {
// Mirror the real helper: it stores the value as JSON; cacheGet
// returns the parsed shape. We skip serialization here and just
// hold the object directly — same observed behavior.
store.set(key, value);
return true;
}),
cacheDel: jest.fn(async (key) => {
store.delete(key);
return true;
}),
isDegraded: jest.fn(() => false),
__store: store,
};
});
const redis = require('../../src/utils/redis');
const tracker = require('../../src/services/quotaTracker');
beforeEach(() => {
redis.__store.clear();
redis.cacheGet.mockClear();
redis.cacheSet.mockClear();
redis.cacheDel.mockClear();
redis.isDegraded.mockReturnValue(false);
process.env.ODDS_API_KEY = 'test-odds-key';
process.env.RAPID_API_KEY = 'test-tank01-key';
process.env.API_FOOTBALL_KEY = 'test-apifoot-key';
process.env.FOOTBALL_DATA_API_KEY = 'test-fd-key';
});
describe('quotaTracker.getPeriodKey', () => {
test('monthly produces YYYY-MM', () => {
const key = tracker.getPeriodKey('odds-api', new Date(Date.UTC(2026, 5, 12)));
expect(key).toBe('2026-06');
});
test('daily produces YYYY-MM-DD', () => {
const key = tracker.getPeriodKey('api-football', new Date(Date.UTC(2026, 5, 12)));
expect(key).toBe('2026-06-12');
});
test('per_minute produces YYYY-MM-DDTHH:MM', () => {
const key = tracker.getPeriodKey('football-data', new Date(Date.UTC(2026, 5, 12, 15, 30)));
expect(key).toBe('2026-06-12T15:30');
});
test('unknown provider returns empty string', () => {
expect(tracker.getPeriodKey('made-up')).toBe('');
});
});
describe('quotaTracker.recordCall', () => {
test('counts up from zero, exposes remaining', async () => {
const a = await tracker.recordCall('odds-api');
expect(a.allowed).toBe(true);
expect(a.used).toBe(1);
expect(a.remaining).toBe(499);
const b = await tracker.recordCall('odds-api');
expect(b.used).toBe(2);
expect(b.remaining).toBe(498);
});
test('returns allowed:false once pct hits 95%', async () => {
// Seed the counter at 95% directly through syncFromHeaders so we
// don't have to fire 475 recordCall iterations.
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '475',
'x-requests-remaining': '25',
});
const status = await tracker.getQuotaStatus('odds-api');
expect(status.used).toBe(475);
expect(status.pct).toBeCloseTo(0.95);
expect(status.allowed).toBe(false);
});
test('logs the WARN line exactly once at 80%', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Seed 399/500 = 79.8% — first recordCall takes us to 400/500 = 80%
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api');
await tracker.recordCall('odds-api');
await tracker.recordCall('odds-api');
const warnings = warnSpy.mock.calls.map((c) => c.join(' ')).filter((m) => /quotaTracker/.test(m));
expect(warnings.length).toBe(1);
expect(warnings[0]).toMatch(/Odds API/);
warnSpy.mockRestore();
});
test('unknown provider → allowed:false', async () => {
const r = await tracker.recordCall('does-not-exist');
expect(r.allowed).toBe(false);
expect(r.reason).toBe('unknown_provider');
});
});
describe('quotaTracker.rollback', () => {
test('decrements the counter without going below zero', async () => {
await tracker.recordCall('tank01');
await tracker.recordCall('tank01');
await tracker.rollback('tank01');
let status = await tracker.getQuotaStatus('tank01');
expect(status.used).toBe(1);
await tracker.rollback('tank01');
await tracker.rollback('tank01');
status = await tracker.getQuotaStatus('tank01');
expect(status.used).toBe(0);
});
});
describe('quotaTracker.syncFromHeaders', () => {
test('odds-api headers overwrite the counter (truth source)', async () => {
await tracker.recordCall('odds-api');
await tracker.recordCall('odds-api');
// Local counter says 2; upstream says 50 — upstream wins.
const synced = await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '50',
'x-requests-remaining': '450',
});
expect(synced.used).toBe(50);
expect(synced.limit).toBe(500);
const status = await tracker.getQuotaStatus('odds-api');
expect(status.used).toBe(50);
expect(status.syncedAt).toBeTruthy();
});
test('infers used from remaining + limit when used header absent', async () => {
const synced = await tracker.syncFromHeaders('odds-api', {
'x-requests-remaining': '120',
'x-quota-limit': '500',
});
expect(synced.used).toBe(380);
expect(synced.limit).toBe(500);
});
test('returns null when no usable headers present', async () => {
const synced = await tracker.syncFromHeaders('odds-api', { 'content-type': 'application/json' });
expect(synced).toBeNull();
});
});
describe('quotaTracker.getTickInterval (scheduler step function)', () => {
test('returns 5min under 50%', () => {
expect(tracker.getTickInterval(0)).toBe(5 * 60 * 1000);
expect(tracker.getTickInterval(0.49)).toBe(5 * 60 * 1000);
});
test('returns 15min at 50%79%', () => {
expect(tracker.getTickInterval(0.5)).toBe(15 * 60 * 1000);
expect(tracker.getTickInterval(0.79)).toBe(15 * 60 * 1000);
});
test('returns 30min at 80%94%', () => {
expect(tracker.getTickInterval(0.8)).toBe(30 * 60 * 1000);
expect(tracker.getTickInterval(0.94)).toBe(30 * 60 * 1000);
});
test('returns null at >=95% (stop)', () => {
expect(tracker.getTickInterval(0.95)).toBeNull();
expect(tracker.getTickInterval(1.0)).toBeNull();
});
});
describe('quotaTracker.shouldThrottle', () => {
test('returns allowed:false and interval:null at exhaustion', async () => {
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '500',
'x-requests-remaining': '0',
});
const out = await tracker.shouldThrottle('odds-api');
expect(out.allowed).toBe(false);
expect(out.interval).toBeNull();
});
test('allowed and 5min interval when healthy', async () => {
const out = await tracker.shouldThrottle('odds-api');
expect(out.allowed).toBe(true);
expect(out.interval).toBe(5 * 60 * 1000);
});
});
describe('quotaTracker degraded-mode fail-open', () => {
test('returns allowed:true degraded:true when Redis is down', async () => {
redis.isDegraded.mockReturnValue(true);
const status = await tracker.getQuotaStatus('odds-api');
expect(status.allowed).toBe(true);
expect(status.degraded).toBe(true);
});
test('recordCall is a no-op when degraded', async () => {
redis.isDegraded.mockReturnValue(true);
const r = await tracker.recordCall('odds-api');
expect(r.allowed).toBe(true);
expect(r.degraded).toBe(true);
expect(redis.cacheSet).not.toHaveBeenCalled();
});
});
describe('quotaTracker.getAllQuotaStatuses', () => {
test('returns one entry per configured provider', async () => {
const statuses = await tracker.getAllQuotaStatuses();
const ids = statuses.map((s) => s.provider).sort();
// ODDS_API_KEY, RAPID_API_KEY, API_FOOTBALL_KEY, FOOTBALL_DATA_API_KEY
// are set in beforeEach; ODDSPAPI_KEY and PARLAYAPI_KEY are not.
expect(ids).toContain('odds-api');
expect(ids).toContain('tank01');
expect(ids).toContain('api-football');
expect(ids).toContain('football-data');
expect(ids).not.toContain('oddspapi');
expect(ids).not.toContain('parlayapi');
});
});