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