tokenbucket
Version:
A flexible rate limiter using different variations of the Token Bucket algorithm, with hierarchy support, and optional persistence in Redis. Useful for limiting API requests, or other tasks that need to be throttled.
799 lines (767 loc) • 29.1 kB
text/coffeescript
'use strict'
chai = require 'chai'
sinon = require 'sinon'
chai.use require 'sinon-chai'
chai.use require 'chai-as-promised'
expect = chai.expect
Promise = require 'bluebird'
# Using the compiled JavaScript file here to be sure that the module works
TokenBucket = require '../lib/tokenbucket'
bucket = null
clock = null
# Helper function that checks that the removal happened inmediately or after the supposed time, and that it leaves the right amount of tokens
checkRemoval = ({tokensRemove, time, tokensLeft, done, clock, parentTokensLeft, nextTickScheduler}) ->
if nextTickScheduler
Promise.setScheduler (fn) ->
process.nextTick fn
bucket.removeTokens(tokensRemove)
.then (remainingTokens) ->
expect(bucket.tokensLeft, 'bucket.tokensLeft').eql tokensLeft if tokensLeft?
if parentTokensLeft
expect(bucket.parentBucket.tokensLeft, 'bucket.parentBucket.tokensLeft').eql parentTokensLeft
if bucket.parentBucket
message = 'remaining with parent: ' + bucket.tokensLeft + ', ' + bucket.parentBucket.tokensLeft + ', ' + remainingTokens
expect(Math.min(bucket.tokensLeft, bucket.parentBucket.tokensLeft) == remainingTokens, message).true
else
message = 'remaining ' + remainingTokens + ', ' + bucket.tokensLeft
expect(bucket.tokensLeft == remainingTokens, message).true
done()
.catch (err) ->
done(err)
# Promises with enough tokens get resolved without any clock tick
if time
done = sinon.spy(done)
clock.tick(time - 1)
expect(done).not.called
clock.tick(1)
describe 'a default tokenbucket', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket()
afterEach ->
clock.restore()
it 'is initialized with the right values', ->
expect(bucket.size).eql 1
expect(bucket.tokensToAddPerInterval).eql 1
expect(bucket.interval).eql 1000
expect(bucket.tokensLeft).eql 1
expect(bucket.lastFill).eql 0 # Fake timer without any tick
expect(bucket.spread).undefined
expect(bucket.redis).undefined
expect(bucket.parentBucket).undefined
describe 'when configuring the instance', ->
parentBucket = new TokenBucket
size: 10
beforeEach (done) ->
bucket.size = 5
bucket.tokensToAddPerInterval = 2
bucket.interval = 500
bucket.tokensLeft = 3
bucket.lastFill = +new Date() - 250 # Fake timer at 0ms minus 250ms
bucket.spread = true
bucket.redis =
bucketName: 'bucket1'
redisClient: 'fakeRedisClient'
bucket.parentBucket = parentBucket
done()
it 'has the right values', ->
expect(bucket.size).eql 5
expect(bucket.tokensToAddPerInterval).eql 2
expect(bucket.interval).eql 500
expect(bucket.tokensLeft).eql 3
expect(bucket.lastFill).eql -250
expect(bucket.spread).true
expect(bucket.redis).eql
bucketName: 'bucket1'
redisClient: 'fakeRedisClient'
expect(bucket.parentBucket).eql parentBucket
it 'works as expected with the configured instance', (done) ->
expect(bucket.tokensLeft).eql 3
checkRemoval
tokensLeft: 3 # had 3 tokens left, plus 1 token (2 tokens per interval / half interval passed added evenly = 1 token), makes 4 tokens, minus 1 token removed = 3
done: done
describe 'removeTokens called without parameter', ->
it 'removes 1 token instantly and leaves 0 tokens', (done) ->
checkRemoval
tokensLeft: 0
done: done
describe 'trying to remove more tokens than the bucket size', ->
it 'rejects the promise with the right error', (done) ->
bucket.removeTokens(2).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'NotEnoughSize'
expect(err.message).eql 'Requested tokens (2) exceed bucket size (1)'
done()
describe 'a tokenbucket with redis options', ->
it 'removes redis if bucketName is not set', ->
bucket = new TokenBucket
redis:
redisClient: 'fakeRedisClient'
redisClientConfig:
port: 1000
expect(bucket.redis).undefined
it 'removes redisClientConfig if redisClient is set', ->
bucket = new TokenBucket
redis:
bucketName: 'bucket1'
redisClient: 'fakeRedisClient'
redisClientConfig:
port: 1000
expect(bucket.redis.redisClientConfig).undefined
it 'sets redisClientConfig defaults', ->
bucket = new TokenBucket
redis:
bucketName: 'bucket1'
expect(bucket.redis.redisClientConfig.port).eql 6379
expect(bucket.redis.redisClientConfig.host).eql '127.0.0.1'
expect(bucket.redis.redisClientConfig.unixSocket).undefined
expect(bucket.redis.redisClientConfig.options).exists
bucket.redis.redisClient.end()
it 'sets redisClientConfig as defined', ->
bucket = new TokenBucket
redis:
bucketName: 'bucket1'
redisClientConfig:
port: 6379
host: 'localhost'
options:
max_attempts: 10
expect(bucket.redis.redisClientConfig.port).eql 6379
expect(bucket.redis.redisClientConfig.host).eql 'localhost'
expect(bucket.redis.redisClientConfig.unixSocket).undefined
expect(bucket.redis.redisClientConfig.options.max_attempts).eql 10
bucket.redis.redisClient.end()
it 'sets unixSocket if defined, and throws and error for the non existing socket', (done) ->
bucket = new TokenBucket
redis:
bucketName: 'bucket1'
redisClientConfig:
unixSocket: '/tmp/fakeredis.sock'
bucket.redis.redisClient.on 'error', (err) ->
expect(err instanceof Error).true
bucket.redis.redisClient.end()
done()
describe 'a tokenbucket initialized with interval string', ->
describe 'when string is second', ->
beforeEach ->
bucket = new TokenBucket
interval: 'second'
it 'is initialized with the right interval', ->
expect(bucket.interval).eql 1000
describe 'when string is minute', ->
beforeEach ->
bucket = new TokenBucket
interval: 'minute'
it 'is initialized with the right interval', ->
expect(bucket.interval).eql 1000 * 60
describe 'when string is hour', ->
beforeEach ->
bucket = new TokenBucket
interval: 'hour'
it 'is initialized with the right interval', ->
expect(bucket.interval).eql 1000 * 60 * 60
describe 'when string is day', ->
beforeEach ->
bucket = new TokenBucket
interval: 'day'
it 'is initialized with the right interval', ->
expect(bucket.interval).eql 1000 * 60 * 60 * 24
describe 'a tokenbucket initialized with maxWait string', ->
describe 'when string is second', ->
beforeEach ->
bucket = new TokenBucket
maxWait: 'second'
it 'is initialized with the right interval', ->
expect(bucket.maxWait).eql 1000
describe 'when string is minute', ->
beforeEach ->
bucket = new TokenBucket
maxWait: 'minute'
it 'is initialized with the right interval', ->
expect(bucket.maxWait).eql 1000 * 60
describe 'when string is hour', ->
beforeEach ->
bucket = new TokenBucket
maxWait: 'hour'
it 'is initialized with the right interval', ->
expect(bucket.maxWait).eql 1000 * 60 * 60
describe 'when string is day', ->
beforeEach ->
bucket = new TokenBucket
maxWait: 'day'
it 'is initialized with the right interval', ->
expect(bucket.maxWait).eql 1000 * 60 * 60 * 24
describe 'a tokenbucket with maxWait', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensLeft: 1
maxWait: 2000
afterEach ->
clock.restore()
it 'will remove tokens when maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when maxWait is exceeded and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'a tokenbucket with maxWait and parent', ->
beforeEach ->
clock = sinon.useFakeTimers()
parentBucket = new TokenBucket
size: 20
tokensLeft: 1
bucket = new TokenBucket
size: 10
maxWait: 2000
parentBucket: parentBucket
afterEach ->
clock.restore()
it 'will remove tokens when maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when maxWait is exceeded because of the parent and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'a tokenbucket with maxWait and parent with smaller maxWait', ->
beforeEach ->
clock = sinon.useFakeTimers()
parentBucket = new TokenBucket
size: 20
tokensLeft: 1
maxWait: 2000
bucket = new TokenBucket
size: 10
maxWait: 100000
parentBucket: parentBucket
afterEach ->
clock.restore()
it 'will remove tokens when maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when maxWait is exceeded because of the parent and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'a tokenbucket with maxWait and parent with bigger maxWait', ->
beforeEach ->
clock = sinon.useFakeTimers()
parentBucket = new TokenBucket
size: 20
tokensLeft: 1
maxWait: 100000
bucket = new TokenBucket
size: 10
maxWait: 2000
parentBucket: parentBucket
afterEach ->
clock.restore()
it 'will remove tokens when maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when maxWait is exceeded because of the child and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'a tokenbucket with a parent with maxWait', ->
beforeEach ->
clock = sinon.useFakeTimers()
parentBucket = new TokenBucket
size: 20
maxWait: 2000
bucket = new TokenBucket
size: 10
tokensLeft: 1
parentBucket: parentBucket
afterEach ->
clock.restore()
it 'will remove tokens when the parent maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when the parent maxWait is exceeded and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'a tokenbucket with a grandparent with maxWait', ->
beforeEach ->
clock = sinon.useFakeTimers()
grandParentBucket = new TokenBucket
size: 50
maxWait: 2000
parentBucket = new TokenBucket
size: 20
parentBucket: grandParentBucket
bucket = new TokenBucket
size: 10
tokensLeft: 1
maxWait: 100000
parentBucket: parentBucket
afterEach ->
clock.restore()
it 'will remove tokens when the parent maxWait is not exceeded', (done) ->
checkRemoval
done: done
it 'will not remove tokens when the grandparent maxWait is exceeded and reject with the right error', (done) ->
bucket.removeTokens(10).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'ExceedsMaxWait'
expect(err.message).eql 'It will exceed maximum waiting time'
done()
describe 'an empty tokenbucket size 2 filled evenly and last filled 1s ago when requesting tokens sync', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 2
spread: true
tokensLeft: 0
lastFill: +new Date() - 1000
afterEach ->
clock.restore()
describe 'when requesting 2 tokens sync', ->
it 'doesn\'t remove them but add 1 token', ->
result = bucket.removeTokensSync 2
expect(result).to.be.false
expect(bucket.tokensLeft).eql 1
describe 'when requesting 1 token sync', ->
it 'removes it and has 0 tokens', ->
result = bucket.removeTokensSync 1
expect(result).to.be.true
expect(bucket.tokensLeft).eql 0
describe 'removeTokensSync called without parameter', ->
it 'removes 1 token and has 0 tokens', ->
result = bucket.removeTokensSync()
expect(result).to.be.true
expect(bucket.tokensLeft).eql 0
describe 'when it has a parent without enough tokens', ->
it 'doesn\'t remove tokens', ->
parentBucket = new TokenBucket
tokensLeft: 0
bucket.parentBucket = parentBucket
result = bucket.removeTokensSync()
expect(result).to.be.false
expect(bucket.tokensLeft).eql 1
describe 'when trying to remove more tokens that its size', ->
it 'doesn\'t remove tokens but adds 1 token', ->
result = bucket.removeTokensSync(3)
expect(result).to.be.false
expect(bucket.tokensLeft).eql 1
describe 'when waiting 500ms and removing 1 token', ->
it 'removes the token and leaves 0.5 tokens', ->
clock.tick 500
result = bucket.removeTokensSync()
expect(result).to.be.true
expect(bucket.tokensLeft).eql 0.5
describe 'a tokenbucket with parent bucket', ->
parentBucket = null
before ->
clock = sinon.useFakeTimers()
parentBucket = new TokenBucket()
bucket = new TokenBucket
size: 2
parentBucket: parentBucket
after ->
clock.restore()
describe 'when removing a token', ->
it 'removes it from the bucket and leaves 1 token', (done) ->
expect(bucket.tokensLeft).eql 2
expect(bucket.parentBucket.tokensLeft).eql 1
checkRemoval
tokensLeft: 1
done: done
it 'removes it from the parent bucket and leaves 0 tokens', ->
expect(parentBucket.tokensLeft).eql 0
describe 'when removing a token and there are no tokens in the parent', ->
it 'waits for the parent to have enough tokens and then removes it from bucket and parent bucket', (done) ->
done = sinon.spy(done)
expect(bucket.tokensLeft).eql 1
expect(bucket.parentBucket.tokensLeft).eql 0
checkRemoval
tokensLeft: 1 # same interval as the parent, after the wait got 1 more token (2 left), after removal there is 1 left
parentTokensLeft: 0 # 1 token after interval, 0 tokens after removal
time: 1000
clock: clock
done: done
nextTickScheduler: true
describe 'when after waiting for the parent doesn\t have enough tokens any more', ->
it 'waits to get enough tokens', (done) ->
done = sinon.spy(done)
bucket.interval = 1500 # greater interval than the parent, so we check that it waits longer than just the parent interval
bucket.removeTokens().then ->
expect(bucket.tokensLeft).eql 0
expect(bucket.parentBucket.tokensLeft).eql 0
done()
clock.tick 500 # some time passed whilst waiting for parent
expect(bucket.tokensLeft).eql 1 # still one token left
expect(bucket.parentBucket.tokensLeft).eql 0 # parent still empty
# empty bucket whilst waiting for parent
bucket.tokensLeft = 0
expect(bucket.tokensLeft).eql 0
expect(bucket.parentBucket.tokensLeft).eql 0
# the parent gets 1 token (500 + 500 = parent interval)
clock.tick 500
# We need nextTick so the previous clock tick gets executed, and that part of the code gets covered
process.nextTick ->
expect(bucket.tokensLeft).eql 0
expect(bucket.parentBucket.tokensLeft).eql 1
clock.tick 499
expect(done).not.called
clock.tick 1 # 1500 total = 1000 parent + 500 itself
describe 'a filled infinite tokenbucket', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: Number.POSITIVE_INFINITY
afterEach ->
clock.restore()
it 'removes tokens inmediately and still has infinite tokens', (done) ->
checkRemoval
tokensRemove: 9999
tokensLeft: Number.POSITIVE_INFINITY
done: done
it 'can\'t remove infinite tokens and rejects with the right error', (done) ->
bucket.removeTokens(Number.POSITIVE_INFINITY).catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'NoInfinityRemoval'
expect(err.message).eql 'Not possible to remove infinite tokens.'
done()
describe 'an empty infinite tokenbucket filled evenly with infinite tokens', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: Number.POSITIVE_INFINITY
tokensToAddPerInterval: Number.POSITIVE_INFINITY
tokensLeft: 0
spread: true
after ->
clock.restore()
it 'removes tokens after at least 1ms and then gets infinite tokens', (done) ->
checkRemoval
tokensRemove: 9999
tokensLeft: Number.POSITIVE_INFINITY
time: 1
clock: clock
done: done
describe 'an empty infinite tokenbucket with 100ms interval', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: Number.POSITIVE_INFINITY
tokensLeft: 0
interval: 100
afterEach ->
clock.restore()
describe 'when removing 1 token', ->
it 'takes 100ms and leaves 0 tokens', (done) ->
checkRemoval
tokensLeft: 0
time: 100
clock: clock
done: done
describe 'a tokenbucket with size 10 adding 1 token per 100ms', ->
describe 'when removing 1 token', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
interval: 100
after ->
clock.restore()
it 'takes the tokens inmediately and leaves 9 tokens', (done) ->
checkRemoval
tokensLeft: 9
done: done
describe 'when removing 10 tokens', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
interval: 100
after ->
clock.restore()
it 'takes the tokens inmediately and leaves 0 tokens', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
done: done
describe 'when removing another 10 tokens', ->
it 'takes 1 second and leaves 0 tokens again', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 1000
clock: clock
done: done
describe 'when waiting 2 seconds and removing 10 tokens', ->
it 'removes the tokens inmediately and leaves the bucket empty', (done) ->
clock.tick 2000
checkRemoval
tokensRemove: 10
tokensLeft: 0
done: done
describe 'a tokenbucket starting empty with size 10 adding 1 token per 100ms', ->
describe 'when removing 1 token', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensToAddPerInterval: 1
interval: 100
tokensLeft: 0
after ->
clock.restore()
it 'takes 100ms and leaves 0 tokens', (done) ->
checkRemoval
tokensLeft: 0
time: 100
clock: clock
done: done
describe 'when removing 10 tokens', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensToAddPerInterval: 1
interval: 100
tokensLeft: 0
after ->
clock.restore()
it 'takes 1 second and leaves 0 tokens', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 1000
clock: clock
done: done
describe 'when removing another 10 tokens', ->
it 'takes 1 second and leaves 0 tokens again', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 1000
clock: clock
done: done
describe 'when waiting 2 seconds and removing 10 tokens', ->
it 'removes the tokens inmediately and leaves the bucket empty', (done) ->
clock.tick 2000
checkRemoval
tokensRemove: 10
tokensLeft: 0
done: done
describe 'a tokenbucket with size 10 adding 5 token per 1 second', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensToAddPerInterval: 5
interval: 1000
after ->
clock.restore()
describe 'when removing 10 tokens', ->
it 'takes them inmediately and leaves 0 tokens', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
done: done
describe 'when removing another 10 tokens', ->
it 'takes 2s and is empty again', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 2000
clock: clock
done: done
describe 'when removing another 1 token', ->
it 'takes 1s and leave 4 tokens left', (done) ->
checkRemoval
tokensLeft: 4
time: 1000
clock: clock
done: done
describe 'a tokenbucket with size 10 adding evenly 5 tokens per 1 second', ->
before ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensToAddPerInterval: 5
interval: 1000
spread: true
after ->
clock.restore()
describe 'when removing 10 tokens', ->
it 'takes them inmediately and leaves 0 tokens', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
done: done
describe 'when removing another 10 tokens', ->
it 'takes 2s and is empty again', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 2000
clock: clock
done: done
describe 'when removing another 1 token', ->
it 'takes 200ms and leaves no tokens', (done) ->
checkRemoval
tokensLeft: 0
time: 200
clock: clock
done: done
describe 'a tokenbucket with size 10 and 5 tokens left adding 1 token per 100ms', ->
beforeEach ->
clock = sinon.useFakeTimers()
bucket = new TokenBucket
size: 10
tokensToAddPerInterval: 1
interval: 100
tokensLeft: 5
after ->
clock.restore()
describe 'when removing 10 token', ->
it 'takes 500ms and leaves 0 tokens', (done) ->
checkRemoval
tokensRemove: 10
tokensLeft: 0
time: 500
clock: clock
done: done
describe 'saving a tokenbucket', ->
stub = null
stubParent = null
parentBucket = null
describe 'when initialized without bucket name', ->
it 'rejects the promise with the right error', (done) ->
redisClient = mset: ->
bucket = new TokenBucket
redis:
redisClient: redisClient
bucket.save().catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'NoRedisOptions'
expect(err.message).eql 'Redis options missing.'
done()
describe 'when initialized with the right options', ->
beforeEach ->
redisClient = mset: ->
bucket = new TokenBucket
redis:
bucketName: 'test'
redisClient: redisClient
stub = sinon.stub(bucket.redis.redisClient, 'mset')
it 'redis command is called with the right parameters and resolves the promise', ->
stub.callsArgWith(4, null, 'OK')
promise = bucket.save()
expect(bucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:test:lastFill', bucket.lastFill, 'tokenbucket:test:tokensLeft', bucket.tokensLeft
expect(promise).to.be.resolved
it 'when callback has error rejects the promise with the error', ->
stub.callsArgWith(4, new Error('db err'))
promise = bucket.save()
expect(promise).to.be.rejectedWith Error, 'db err'
describe 'when has parent bucket with redis options', ->
beforeEach ->
parentRedisClient = mset: ->
parentBucket = new TokenBucket
redis:
bucketName: 'testParent'
redisClient: parentRedisClient
redisClient = mset: ->
bucket = new TokenBucket
redis:
bucketName: 'test'
redisClient: redisClient
parentBucket: parentBucket
stubParent = sinon.stub(parentBucket.redis.redisClient, 'mset').yields(Promise.pending().resolve())
stub = sinon.stub(bucket.redis.redisClient, 'mset')
it 'parent bucket gets called with the right parameters, then its save() promise resolves, and then redis command is called in the child bucket with the right parameters and resolves the promise', (done) ->
stub.callsArgWith(4, null, 'OK')
stubParent.callsArgWith(4, null, 'OK')
bucket.save().then ->
expect(parentBucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:testParent:lastFill', parentBucket.lastFill, 'tokenbucket:testParent:tokensLeft', parentBucket.tokensLeft
expect(bucket.redis.redisClient.mset).to.have.been.calledWith 'tokenbucket:test:lastFill', bucket.lastFill, 'tokenbucket:test:tokensLeft', bucket.tokensLeft
done()
it 'when parent callback has error rejects the promise with the error', ->
stubParent.callsArgWith(4, new Error('db err parent'))
expect(bucket.save()).to.be.rejectedWith Error, 'db err parent'
describe 'load a saved tokenbucket', ->
stub = null
stubParent = null
parentBucket = null
lastFill = +new Date()
lastFillSaved = +new Date() - 5000
tokensLeftSaved = 3
describe 'when initialized without bucket name', ->
it 'rejects the promise with the right error', (done) ->
redisClient = mget: ->
bucket = new TokenBucket
redis:
redisClient: redisClient
bucket.loadSaved().catch (err) ->
expect(err instanceof Error).true
expect(err.name).eql 'NoRedisOptions'
expect(err.message).eql 'Redis options missing.'
done()
describe 'when initialized with the right options', ->
beforeEach ->
redisClient = mget: ->
bucket = new TokenBucket
redis:
bucketName: 'test'
redisClient: redisClient
bucket.lastFill = lastFill
stub = sinon.stub(bucket.redis.redisClient, 'mget')
it 'calls the redis command with the right parameters and loads the bucket with the returned data', ->
stub.callsArgWith 2, null, [lastFillSaved, tokensLeftSaved]
promise = bucket.loadSaved()
expect(bucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:test:lastFill', 'tokenbucket:test:tokensLeft'
expect(promise).to.be.resolved
expect(bucket.lastFill).to.eql lastFillSaved
expect(bucket.tokensLeft).to.eql tokensLeftSaved
it 'leaves the original data if no data is returned', ->
stub.callsArgWith 2, null, [null, null]
promise = bucket.loadSaved()
expect(promise).to.be.resolved
expect(bucket.lastFill).to.eql lastFill
expect(bucket.tokensLeft).to.eql 1
it 'when callback has error rejects the promise with the error', ->
stub.callsArgWith(2, new Error('db err'))
promise = bucket.loadSaved()
expect(promise).to.be.rejectedWith Error, 'db err'
describe 'when has parent bucket with redis options', ->
beforeEach ->
parentRedisClient = mget: ->
parentBucket = new TokenBucket
redis:
bucketName: 'testParent'
redisClient: parentRedisClient
redisClient = mget: ->
bucket = new TokenBucket
redis:
bucketName: 'test'
redisClient: redisClient
parentBucket: parentBucket
stubParent = sinon.stub(parentBucket.redis.redisClient, 'mget').yields(Promise.pending().resolve())
stub = sinon.stub(bucket.redis.redisClient, 'mget')
it 'parent bucket gets called with the right parameters, then its loadSaved() promise resolves, and then redis command is called in the child bucket with the right parameters and resolves the promise', (done) ->
stub.callsArgWith(2, null, [lastFillSaved, tokensLeftSaved])
stubParent.callsArgWith(2, null, [lastFillSaved, tokensLeftSaved])
bucket.loadSaved().then ->
expect(parentBucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:testParent:lastFill', 'tokenbucket:testParent:tokensLeft'
expect(bucket.redis.redisClient.mget).to.have.been.calledWith 'tokenbucket:test:lastFill', 'tokenbucket:test:tokensLeft'
done()
it 'when parent callback has error rejects the promise with the error', ->
stubParent.callsArgWith(2, new Error('db err parent'))
expect(bucket.loadSaved()).to.be.rejectedWith Error, 'db err parent'