Home Reference Source Test Repository

test/RateLimiter.test.js

'use strict';
const chai       = require('chai');
const sinon      = require('sinon');
const sinonChai  = require('sinon-chai');
const proxyquire = require('proxyquire');
const _          = require('lodash');
const START_TIME = 140000;

chai.use(sinonChai);

const expect = chai.expect;

describe('RateLimiter', () => {
  let RateLimiter;
  let rateLimiter;
  let config;
  let key;
  let TokenBucketStub;
  let sandbox;
  let clock;

  beforeEach(() => {
    config = {
      limit             : 20,
      incrementInterval : 5000,
      increment         : 2,
    };
    const TokenBucket = require('../src/TokenBucket.js');
    const mockTokenBucket = sinon.createStubInstance(TokenBucket);
    TokenBucketStub = sinon.stub().returns(mockTokenBucket);

    RateLimiter = proxyquire('../src/RateLimiter.js', {
      './TokenBucket': TokenBucketStub,
    });
    clock = sinon.useFakeTimers(START_TIME);
  });

  afterEach(() => {
    clock.restore();
  });

  describe('#constructor', () => {
    it('should not error if not given a config', () => {
      expect(() => { return new RateLimiter(); }).not.to.throw(Error);
    });

    describe('without config', () => {

      beforeEach(() => {
        rateLimiter = new RateLimiter();
      });

      afterEach(() => {
        rateLimiter.destroy();
      });

      it('should return an object', () => {
        expect(rateLimiter).to.be.an('object');
      });

      it('should default config.limit to null', () => {
        expect(rateLimiter.config.limit).to.not.exist;
      });

      it('should default config.incrementInterval to null', () => {
        expect(rateLimiter.config.incrementInterval).to.not.exist;
      });

      it('should default config.increment to null', () => {
        expect(rateLimiter.config.increment).to.not.exist;
      });

      it('should create tokenBuckets', () => {
        expect(rateLimiter.tokenBuckets).to.exist;
      });

    });

    describe('with config', () => {

      beforeEach(() => {
        config = {
          limit             : 200,
          incrementInterval : 8000,
          increment         : 2,
        };
        rateLimiter = new RateLimiter(config);
      });

      afterEach(() => {
        rateLimiter.destroy();
      });

      it('should return an object', () => {
        expect(rateLimiter).to.be.an('object');
      });

      it('should assign given incrementInterval to config.limit', () => {
        expect(rateLimiter.config.limit).to.equal(config.limit);
      });

      it('should assign given incrementInterval to config.incrementInterval', () => {
        expect(rateLimiter.config.incrementInterval).to.equal(config.incrementInterval);
      });

      it('should assign given incrementInterval to config.increment', () => {
        expect(rateLimiter.config.increment).to.equal(config.increment);
      });

      it('should create tokenBuckets', () => {
        expect(rateLimiter.tokenBuckets).to.exist;
      });

    });

  });

  describe('#destroy', () => {
    let destroyOne;
    let destroyTwo;
    let destroyThree;
    let sandbox;

    beforeEach(() => {
      rateLimiter = new RateLimiter(config);
      rateLimiter.getTokensRemaining('one');
      rateLimiter.getTokensRemaining('two');
      rateLimiter.getTokensRemaining('three');
      destroyOne = rateLimiter.tokenBuckets.one.destroy;
      destroyTwo = rateLimiter.tokenBuckets.two.destroy;
      destroyThree = rateLimiter.tokenBuckets.three.destroy;
      rateLimiter.destroy();
    });

    it('should call destroy() on each key in tokenBuckets', () => {
      expect(destroyOne).to.have.been.called;
      expect(destroyTwo).to.have.been.called;
      expect(destroyThree).to.have.been.called;
    });

    it('should null out the objects', () => {
      expect(rateLimiter.tokenBuckets.one).to.not.exist;
      expect(rateLimiter.tokenBuckets.two).to.not.exist;
      expect(rateLimiter.tokenBuckets.three).to.not.exist;
    });

  });

  describe('#decrementTokens', () => {
    let amount;

    beforeEach(() => {
      rateLimiter = new RateLimiter(config);
      key = 'testorama';
      amount = 5;
    });

    afterEach(() => {
      rateLimiter.destroy();
    });

    describe('key validation', () => {

      it('should error if nothing is given', () => {
        expect(() => { rateLimiter.decrementTokens(); }).to.throw(Error);
      });

      describe('when key is not on an object', () => {

        it('should error on int', () => {
          expect(() => { rateLimiter.decrementTokens(3); }).to.throw(Error);
        });

        it('should error on function', () => {
          expect(() => { rateLimiter.decrementTokens(() => {}); }).to.throw(Error);
        });

        it('should error on object', () => {
          expect(() => { rateLimiter.decrementTokens({ key }); }).to.throw(Error);
        });

      });

      it('should not error on valid key', () => {
        expect(() => { rateLimiter.decrementTokens(key); }).to.not.throw(Error);
      });

    });

    it('should call TokenBucket if the key is new', () => {
      rateLimiter.decrementTokens(key);
      expect(TokenBucketStub).to.have.been.calledWith(config);
    });

    it('should not call TokenBucket if the key already exists', () => {
      rateLimiter.decrementTokens(key);
      rateLimiter.decrementTokens(key);
      rateLimiter.decrementTokens(key);
      rateLimiter.decrementTokens(key);
      expect(TokenBucketStub).to.have.been.calledOnce;
    });

    it('should call decrementTokens on TokenBucket instance', () => {
      rateLimiter.decrementTokens(key);
      expect(rateLimiter.tokenBuckets[key].decrementTokens).to.have.been.called;
    });

    it('should call decrementTokens with an amount if given one', () => {
      rateLimiter.decrementTokens(key, 5);
      expect(rateLimiter.tokenBuckets[key].decrementTokens).to.have.been.calledWith(5);
    });

  });

  describe('#getTokensRemaining', () => {

    describe('when request within limits', () => {

      beforeEach(() => {
        rateLimiter = new RateLimiter(config);
        key = 'test';
      });

      afterEach(() => {
        rateLimiter.destroy();
      });

      describe('key validation', () => {

        it('should error if no key is given', () => {
          expect(() => { rateLimiter.getTokensRemaining(); }).to.throw(Error);
        });

        describe('when key is not a string', () => {

          it('should error on int', () => {
            expect(() => { rateLimiter.getTokensRemaining(3); }).to.throw(Error);
          });

          it('should error on function', () => {
            expect(() => { rateLimiter.getTokensRemaining(() => {}); }).to.throw(Error);
          });

          it('should error on object', () => {
            expect(() => { rateLimiter.getTokensRemaining({ test: 'object' }) }).to.throw(Error);
          });

        });

        it('should do nothing if a key is given', () => {
          expect(() => { rateLimiter.getTokensRemaining('anything'); }).to.not.throw(Error);
        });

      });

      it('should call TokenBucket if the key is new', () => {
        rateLimiter.getTokensRemaining(key);
        expect(TokenBucketStub).to.have.been.calledWith(config);
      });

      it('should not call TokenBucket if the key already exists', () => {
        rateLimiter.getTokensRemaining(key);
        rateLimiter.getTokensRemaining(key);
        rateLimiter.getTokensRemaining(key);
        rateLimiter.getTokensRemaining(key);
        expect(TokenBucketStub).to.have.been.calledOnce;
      });

      it('should call getTokensRemaining on TokenBucket instance', () => {
        rateLimiter.getTokensRemaining(key);
        expect(rateLimiter.tokenBuckets[key].getTokensRemaining).to.have.been.called;
      });

    });

    it('should clean up tokenBuckets every incrementInterval', () => {
      rateLimiter = new RateLimiter(config);
      rateLimiter.getTokensRemaining('one');
      rateLimiter.getTokensRemaining('two');
      rateLimiter.getTokensRemaining('three');
      clock.tick(config.incrementInterval);
      expect(rateLimiter.tokenBuckets).to.be.empty;
    });

    it('should not clean up tokenBuckets that are active', () => {
      rateLimiter = new RateLimiter(config);
      rateLimiter.tokenBuckets = {
        'one' : { tokens : config.limit, limit : config.limit, destroy : () => {} },
        'two' : { tokens : config.limit, limit : config.limit, destroy : () => {} },
      };
      rateLimiter.getTokensRemaining('three');
      const bucketThree = rateLimiter._getTokenBucket('three');
      bucketThree.tokens = 9;
      clock.tick(config.incrementInterval);
      expect(rateLimiter.tokenBuckets).to.eql({ 'three' : bucketThree });
    });

  });

});