const { createLimiter, createCircuitBreaker, API_BUDGETS } = require('../../src/utils/rateLimiter'); describe('createLimiter', () => { test('first N tokens within capacity resolve immediately', async () => { const limiter = createLimiter({ tokensPerInterval: 3, interval: 60_000 }); const start = Date.now(); expect(await limiter.waitForToken()).toBe(true); expect(await limiter.waitForToken()).toBe(true); expect(await limiter.waitForToken()).toBe(true); expect(Date.now() - start).toBeLessThan(100); }); test('fourth token in a small bucket waits for refill', async () => { const limiter = createLimiter({ tokensPerInterval: 2, interval: 200 }); expect(await limiter.waitForToken()).toBe(true); expect(await limiter.waitForToken()).toBe(true); const start = Date.now(); expect(await limiter.waitForToken(2_000)).toBe(true); const elapsed = Date.now() - start; // Should take roughly interval/tokens = 100ms before refill yields one. expect(elapsed).toBeGreaterThan(50); expect(elapsed).toBeLessThan(500); }); test('timeout returns false (does not throw) so caller can proceed', async () => { const limiter = createLimiter({ tokensPerInterval: 1, interval: 60_000 }); expect(await limiter.waitForToken()).toBe(true); // consume the one token const result = await limiter.waitForToken(120); // wait briefly, then bail expect(result).toBe(false); }); test('throws on invalid config', () => { expect(() => createLimiter({ tokensPerInterval: 0, interval: 1000 })).toThrow(); expect(() => createLimiter({ tokensPerInterval: 1, interval: 0 })).toThrow(); }); }); describe('createCircuitBreaker', () => { test('closed initially; success resets failure count', async () => { const cb = createCircuitBreaker({ failureThreshold: 2, resetTimeout: 60_000 }); const result = await cb.call(async () => 'ok'); expect(result).toBe('ok'); expect(cb.snapshot().state).toBe('closed'); }); test('opens after threshold failures and rejects further calls', async () => { const cb = createCircuitBreaker({ failureThreshold: 2, resetTimeout: 60_000 }); await expect(cb.call(async () => { throw new Error('boom'); })).rejects.toThrow('boom'); await expect(cb.call(async () => { throw new Error('boom'); })).rejects.toThrow('boom'); expect(cb.snapshot().state).toBe('open'); await expect(cb.call(async () => 'should never run')).rejects.toMatchObject({ code: 'CIRCUIT_OPEN' }); }); test('transitions to half-open after resetTimeout, closes on success', async () => { const cb = createCircuitBreaker({ failureThreshold: 1, resetTimeout: 50 }); await expect(cb.call(async () => { throw new Error('x'); })).rejects.toThrow(); expect(cb.snapshot().state).toBe('open'); await new Promise((r) => setTimeout(r, 80)); // Reading the snapshot triggers the transition check. expect(cb.snapshot().state).toBe('half_open'); const result = await cb.call(async () => 'recovered'); expect(result).toBe('recovered'); expect(cb.snapshot().state).toBe('closed'); }); test('half-open failure re-opens the circuit immediately', async () => { const cb = createCircuitBreaker({ failureThreshold: 1, resetTimeout: 30 }); await expect(cb.call(async () => { throw new Error('first'); })).rejects.toThrow(); await new Promise((r) => setTimeout(r, 60)); await expect(cb.call(async () => { throw new Error('second'); })).rejects.toThrow('second'); expect(cb.snapshot().state).toBe('open'); }); }); describe('API_BUDGETS', () => { test('contains the five named upstreams', () => { expect(API_BUDGETS).toMatchObject({ sharpApi: { tokensPerInterval: 10, interval: 60_000 }, espn: { tokensPerInterval: 2, interval: 60_000 }, mlbStats: { tokensPerInterval: 2, interval: 60_000 }, oddsPapi: { tokensPerInterval: 5, interval: 60_000 }, openRouter: { tokensPerInterval: 15, interval: 60_000 }, }); }); test('is frozen — adapters cannot mutate it accidentally', () => { expect(Object.isFrozen(API_BUDGETS)).toBe(true); }); });