Files
vyndr/tests/unit/quotaTracker.test.js
T

224 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');
});
});