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

344 lines
13 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.
// Session 21 — mock axios so the ntfy POST inside recordCall
// doesn't try to hit the network. The mock is scoped per-test via
// resetAllMocks below.
jest.mock('axios', () => ({ post: jest.fn(async () => ({ status: 200 })) }));
const axios = require('axios');
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);
axios.post.mockClear();
delete process.env.NTFY_URL;
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';
});
// Helper for the ntfy block — awaits a microtask flush so the
// fire-and-forget `sendQuotaAlert(...).catch(...)` inside
// recordCall has resolved before assertions run. Without this the
// axios.post mock can be observed pre-call from inside the test.
const flushAsync = () => new Promise((r) => setImmediate(r));
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 ntfy alerts (Session 21)', () => {
test('does NOT post to ntfy when NTFY_URL is unset', async () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
test('posts WARN to ntfy at 80% with priority 4', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api'); // 400/500 → 80%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [url, body, opts] = axios.post.mock.calls[0];
expect(url).toBe('https://alerts.example.com/vyndr');
expect(body).toMatch(/Odds API/);
expect(body).toMatch(/80%/);
expect(opts.headers.Priority).toBe('4');
expect(opts.headers.Title).toMatch(/Warning/);
warnSpy.mockRestore();
});
test('posts BLOCK to ntfy at 95% with priority 5', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '474',
'x-requests-remaining': '26',
});
await tracker.recordCall('odds-api'); // 475/500 → 95%
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body, opts] = axios.post.mock.calls[0];
expect(body).toMatch(/BLOCKED|95%/);
expect(opts.headers.Priority).toBe('5');
expect(opts.headers.Title).toMatch(/BLOCKED/);
warnSpy.mockRestore();
});
test('dedupes — second recordCall in the same period does not re-post', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
await tracker.recordCall('odds-api');
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
test('WARN→BLOCK transition fires BOTH alerts (separate dedupe keys)', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
// Seed at 79%; first recordCall → 80% (WARN).
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '395',
'x-requests-remaining': '105',
});
await tracker.recordCall('odds-api'); // 396 → 79.2% — under warn
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(0);
// Jump to 95% — should fire BLOCK even though WARN never fired.
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '474',
'x-requests-remaining': '26',
});
await tracker.recordCall('odds-api'); // 475 → 95% — BLOCK
await flushAsync();
expect(axios.post).toHaveBeenCalledTimes(1);
expect(axios.post.mock.calls[0][2].headers.Priority).toBe('5');
warnSpy.mockRestore();
});
test('axios.post failure does NOT break recordCall', async () => {
process.env.NTFY_URL = 'https://alerts.example.com/vyndr';
axios.post.mockRejectedValueOnce(new Error('ntfy down'));
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
await tracker.syncFromHeaders('odds-api', {
'x-requests-used': '399',
'x-requests-remaining': '101',
});
const result = await tracker.recordCall('odds-api');
await flushAsync();
// recordCall returns the normal status even when ntfy throws.
expect(result.allowed).toBe(true);
expect(result.used).toBe(400);
warnSpy.mockRestore();
});
});
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');
});
});