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