memoizeasync
Version:
Helper for memoizing async functions and methods
597 lines (552 loc) • 25 kB
JavaScript
/*global describe, it, beforeEach, afterEach*/
var memoizeAsync = require('../lib/memoizeAsync'),
LRUCache = require('lru-cache'),
expect = require('unexpected').clone().installPlugin(require('unexpected-sinon')),
passError = require('passerror'),
async = require('async'),
sinon = require('sinon');
describe('memoizeAsync', function () {
describe('on a zero-param function', function () {
it('should keep returning the same result', function (done) {
var nextNumber = 1,
memoizedGetNextNumber = memoizeAsync(function getNextNumber(cb) {
process.nextTick(function () {
cb(null, nextNumber);
nextNumber += 1;
});
});
memoizedGetNextNumber(function (err, nextNumber) {
expect(err, 'to be falsy');
expect(nextNumber, 'to equal', 1);
memoizedGetNextNumber(function (err, nextNextNumber) {
expect(err, 'to be falsy');
expect(nextNextNumber, 'to equal', 1);
done();
});
});
});
});
describe('on a multi-param function', function () {
it('should only return the same result when the parameters are the same', function (done) {
var nextNumber = 1,
memoizedSumOfOperandsPlusNextNumber = memoizeAsync(function sumOfOperandsPlusNextNumber(op1, op2, cb) {
process.nextTick(function () {
cb(null, op1 + op2 + nextNumber);
nextNumber += 1;
});
});
memoizedSumOfOperandsPlusNextNumber(10, 10, passError(done, function (sumPlusNextNumber) {
expect(sumPlusNextNumber, 'to equal', 21);
expect(memoizedSumOfOperandsPlusNextNumber.peek(10, 10)[1], 'to equal', 21);
memoizedSumOfOperandsPlusNextNumber(10, 10, passError(done, function (sumPlusNextNextNumber) {
expect(sumPlusNextNextNumber, 'to equal', 21);
memoizedSumOfOperandsPlusNextNumber(10, 20, passError(done, function (sumPlusNextNextNextNumber) {
expect(sumPlusNextNextNextNumber, 'to equal', 32);
memoizedSumOfOperandsPlusNextNumber.purge(10, 20);
memoizedSumOfOperandsPlusNextNumber(10, 20, passError(done, function (number) {
expect(number, 'to equal', 33);
memoizedSumOfOperandsPlusNextNumber(10, 10, passError(done, function (number) {
expect(number, 'to equal', 21);
memoizedSumOfOperandsPlusNextNumber.purgeAll();
memoizedSumOfOperandsPlusNextNumber(10, 20, passError(done, function (number) {
expect(number, 'to equal', 34);
memoizedSumOfOperandsPlusNextNumber(10, 10, passError(done, function (number) {
expect(number, 'to equal', 25);
done();
}));
}));
}));
}));
}));
}));
}));
});
});
it('should produce a function that works as a method', function (done) {
function Counter() {
this.nextNumber = 1;
}
Counter.prototype.getNextNumber = memoizeAsync(function (cb) {
var that = this;
process.nextTick(function () {
cb(null, that.nextNumber);
that.nextNumber += 1;
});
});
var counter = new Counter();
counter.getNextNumber(passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
expect(counter.nextNumber, 'to equal', 2);
counter.getNextNumber(passError(done, function (nextNextNumber) {
expect(nextNextNumber, 'to equal', 1);
expect(counter.nextNumber, 'to equal', 2);
done();
}));
}));
});
it('should deliver the same result to multiple callbacks that are queued before the result is available', function (done) {
var nextNumber = 1,
memoizedGetNextNumber = memoizeAsync(function getNextNumber(cb) {
process.nextTick(function () {
cb(null, nextNumber);
nextNumber += 1;
});
});
var results = [];
function receiveResultAndProceedIfReady(err, number) {
expect(err, 'to be falsy');
results.push(number);
if (results.length === 2) {
expect(results[0], 'to equal', 1);
expect(results[1], 'to equal', 1);
done();
}
}
memoizedGetNextNumber(receiveResultAndProceedIfReady);
memoizedGetNextNumber(receiveResultAndProceedIfReady);
});
describe('with a custom argumentsStringifier', function () {
it('should use it instead of String(...)', function (done) {
function toCanonicalJson(obj) {
return JSON.stringify((function traverseAndSortKeys(obj) {
if (Array.isArray(obj)) {
return obj.map(traverseAndSortKeys);
} else if (typeof obj === 'object' && obj !== null) {
var resultObj = {};
Object.keys(obj).sort().forEach(function (key) {
resultObj[key] = traverseAndSortKeys(obj[key]);
});
return resultObj;
} else {
return obj;
}
}(obj)));
}
var nextNumber = 1,
memoizedGetNextNumber = memoizeAsync(function getNextNumber(obj, cb) {
process.nextTick(function () {
cb(null, nextNumber);
nextNumber += 1;
});
}, {
argumentsStringifier: function (args) {
return args.map(toCanonicalJson).join('\x1d');
}
});
memoizedGetNextNumber({foo: 'bar', quux: 'baz'}, passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
memoizedGetNextNumber({quux: 'baz', foo: 'bar'}, passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
memoizedGetNextNumber({barf: 'baz'}, passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 2);
done();
}));
}));
}));
});
describe('that returns false', function () {
it('should bypass the memoization', function (done) {
var nextNumber = 1,
memoizedGetNextNumber = memoizeAsync(function getNextNumber(str, cb) {
process.nextTick(function () {
cb(null, nextNumber);
nextNumber += 1;
});
}, {
argumentsStringifier: function () {
return false;
}
});
memoizedGetNextNumber('foo', passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
memoizedGetNextNumber('foo', passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 2);
done();
}));
}));
});
});
});
describe('with a maxAge', function () {
it('should recompute the value after an item has become stale', function (done) {
var nextNumber = 1,
memoizedGetNextNumber = memoizeAsync(function getNextNumber(cb) {
process.nextTick(function () {
cb(null, nextNumber);
nextNumber += 1;
});
}, {maxAge: 10});
memoizedGetNextNumber(passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
memoizedGetNextNumber(passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 1);
setTimeout(function () {
memoizedGetNextNumber(passError(done, function (nextNumber) {
expect(nextNumber, 'to equal', 2);
done();
}));
}, 15);
}));
}));
});
});
describe('with a max limit', function () {
it('with a max limit should purge the least recently used result', function (done) {
var nextNumber = 1,
memoizedGetNextNumberPlusOtherNumber = memoizeAsync(function getNextNumber(otherNumber, cb) {
process.nextTick(function () {
cb(null, otherNumber + nextNumber);
nextNumber += 1;
});
}, {max: 2});
memoizedGetNextNumberPlusOtherNumber(1, passError(done, function (nextNumberPlusOne) {
expect(nextNumberPlusOne, 'to equal', 2);
memoizedGetNextNumberPlusOtherNumber(2, passError(done, function (nextNumberPlusTwo) {
expect(nextNumberPlusTwo, 'to equal', 4);
memoizedGetNextNumberPlusOtherNumber(1, passError(done, function (nextNumberPlusOneAgain) {
expect(nextNumberPlusOne, 'to equal', 2);
// This will purge memoizedGetNextNumberPlusOtherNumber(2, ...):
memoizedGetNextNumberPlusOtherNumber(3, passError(done, function (nextNumberPlusThree) {
expect(nextNumberPlusThree, 'to equal', 6);
memoizedGetNextNumberPlusOtherNumber(2, passError(done, function (nextNumberPlusTwoAgain) {
expect(nextNumberPlusTwoAgain, 'to equal', 6);
done();
}));
}));
}));
}));
}));
});
});
describe('with a length function', function () {
it('should call the length function with the result callback parameters as regular arguments', function (done) {
var shouldErrorNextTime = false,
functionThatErrorsEverySecondTime = function (number, cb) {
process.nextTick(function () {
if (shouldErrorNextTime) {
cb(new Error());
} else {
cb(null, 'the result');
}
shouldErrorNextTime = !shouldErrorNextTime;
});
},
memoizedFunctionThatErrorsEverySecondTime = memoizeAsync(functionThatErrorsEverySecondTime, {
length: function (err, result) {
return err ? 1 : result.length;
},
errors: true
});
memoizedFunctionThatErrorsEverySecondTime(1, passError(done, function (result) {
expect(result, 'to equal', 'the result');
expect(memoizedFunctionThatErrorsEverySecondTime.cache.length, 'to equal', 10);
memoizedFunctionThatErrorsEverySecondTime(2, function (err, result2) {
expect(err, 'to be an', Error);
expect(result2, 'to be undefined');
expect(memoizedFunctionThatErrorsEverySecondTime.cache.length, 'to equal', 11);
done();
});
}));
});
});
it('should leave unrelated values in the cache when purgeAll is called', function (done) {
var memoizedAsyncSum = memoizeAsync(function asyncSum(a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
});
var cache = memoizedAsyncSum.cache;
memoizedAsyncSum(1, 2, passError(done, function (sum) {
expect(sum, 'to equal', 3);
expect(cache.keys().length, 'to equal', 1);
cache.set('foo', 'bar');
expect(cache.keys().length, 'to equal', 2);
memoizedAsyncSum.purgeAll();
expect(cache.keys().length, 'to equal', 1);
expect(cache.get('foo'), 'to equal', 'bar');
done();
}));
});
it('should allow passing an existing lru-cache instance in the options object', function (done) {
function asyncSum(a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
}
var cache = new LRUCache(),
memoizedAsyncSum1 = memoizeAsync(asyncSum, {cache: cache});
expect(memoizedAsyncSum1.cache, 'to be', cache);
var memoizedAsyncSum2 = memoizeAsync(asyncSum, {cache: cache});
expect(memoizedAsyncSum2.cache, 'to be', cache);
memoizedAsyncSum1(1, 2, passError(done, function (sum) {
expect(sum, 'to equal', 3);
expect(cache.keys().length, 'to equal', 1);
expect(cache.get(memoizedAsyncSum1.cacheKeyPrefix + memoizedAsyncSum1.argumentsStringifier([1, 2])), 'to equal', [null, 3]);
memoizedAsyncSum2(1, 2, passError(done, function (sum) {
expect(sum, 'to equal', 3);
expect(cache.keys().length, 'to equal', 2);
done();
}));
}));
});
it('should allow specifying a custom cacheKeyPrefix', function (done) {
var memoizedAsyncSum = memoizeAsync(function (a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
}, {
cacheKeyPrefix: 999
});
expect(memoizedAsyncSum.cacheKeyPrefix, 'to equal', '999');
memoizedAsyncSum(1, 2, passError(done, function (sum) {
expect(sum, 'to equal', 3);
expect(memoizedAsyncSum.cache.get('999' + memoizedAsyncSum.argumentsStringifier([1, 2])), 'to equal', [null, 3]);
done();
}));
});
it('should call the memoized function in options.context if specified', function (done) {
var memoizedFunction = memoizeAsync(function (a, cb) {
var sum = this.foo + a;
process.nextTick(function () {
cb(null, sum);
});
}, {context: {foo: 4}});
memoizedFunction(8, passError(done, function (sum) {
expect(sum, 'to equal', 12);
done();
}));
});
it('should not memoize errors per default', function (done) {
var memoizedFunction = memoizeAsync(function (cb) {
process.nextTick(function () {
cb(new Error('foo'));
});
});
memoizedFunction(function (err) {
expect(err, 'to be an', Error);
expect(err.message, 'to equal', 'foo');
memoizedFunction(function (err2) {
expect(err2, 'to be an', Error);
expect(err2.message, 'to equal', 'foo');
expect(err2, 'not to be', err);
done();
});
});
});
it('should memoize errors if the `errors` option is set to true', function (done) {
var memoizedFunction = memoizeAsync(function (cb) {
process.nextTick(function () {
cb(new Error('foo'));
});
}, {errors: true});
memoizedFunction(function (err) {
expect(err, 'to be an', Error);
expect(err.message, 'to equal', 'foo');
memoizedFunction(function (err2) {
expect(err2, 'to be an', Error);
expect(err2.message, 'to equal', 'foo');
expect(err2, 'to be', err);
done();
});
});
});
describe('with refreshAge', function () {
var clock;
var currentTick = 0;
function gotoTick(n) {
var toTick = n - currentTick;
currentTick = n;
clock.tick(toTick);
}
beforeEach(function () {
currentTick = 0;
clock = sinon.useFakeTimers();
});
afterEach(function () {
clock.restore();
});
it('should refresh async but return stale value immediately', function (done) {
var nextNumber = 1,
method = sinon.spy(function getNextNumber(cb) {
setTimeout(function () {
cb(null, nextNumber);
nextNumber += 1;
}, 5);
}),
memoizedGetNextNumber = memoizeAsync(method, { maxAge: 20, refreshAge: 10 });
async.series([
function (callback) {
// initial call
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called once');
gotoTick(10);
callback();
}));
// going to tick 6 to allow it to finish
gotoTick(6);
},
function (callback) {
// still below refreshAge
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called once');
gotoTick(17);
callback();
}));
},
function (callback) {
// above refreshAge below maxAge immediate result
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called twice');
gotoTick(27);
callback();
}));
},
function (callback) {
// above initial maxAge but immediate result because refresh
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 2);
expect(method, 'was called twice');
callback();
}));
}
], done);
});
it('should allow updating refreshTime on the fly', function (done) {
// Reusing the test from above to assert that you can change the refreshAge on the fly.
var nextNumber = 1,
method = sinon.spy(function getNextNumber(cb) {
setTimeout(function () {
cb(null, nextNumber);
nextNumber += 1;
}, 5);
}),
memoizedGetNextNumber = memoizeAsync(function (callback) {
method(callback);
memoizedGetNextNumber.refreshAge = 10;
}, { maxAge: 20, refreshAge: 100 });
async.series([
function (callback) {
// initial call
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called once');
gotoTick(10);
callback();
}));
// going to tick 6 to allow it to finish
gotoTick(6);
},
function (callback) {
// still below refreshAge
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called once');
gotoTick(17);
callback();
}));
},
function (callback) {
// above refreshAge below maxAge immediate result
memoizedGetNextNumber(passError(callback, function (nextNumber) {
expect(nextNumber, 'to be', 1);
expect(method, 'was called twice');
callback();
}));
}
], done);
});
});
describe('#getTtl', function () {
it('should return -1 for a non-existent value', function () {
expect(memoizeAsync(setImmediate).getTtl(), 'to equal', -1);
});
it('should return undefined when there is no maxAge', function (done) {
var memoizedAsyncSum = memoizeAsync(function (a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
});
memoizedAsyncSum(1, 2, function () {
expect(memoizedAsyncSum.getTtl(1, 2), 'to be undefined');
done();
});
});
it('should return the ttl in milliseconds when a maxAge is defined', function (done) {
var memoizedAsyncSum = memoizeAsync(function (a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
}, {
maxAge: 1000
});
memoizedAsyncSum(1, 2, function () {
expect(memoizedAsyncSum.getTtl(1, 2), 'to be a number');
done();
});
});
});
describe('#getAge', function () {
it('should return -1 for a non-existent value', function () {
expect(memoizeAsync(setImmediate).getAge(), 'to equal', -1);
});
it('should return undefined when there is no maxAge', function (done) {
var memoizedAsyncSum = memoizeAsync(function (a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
});
memoizedAsyncSum(1, 2, function () {
expect(memoizedAsyncSum.getAge(1, 2), 'to be undefined');
done();
});
});
it('should return the age in milliseconds when a maxAge is defined', function (done) {
var memoizedAsyncSum = memoizeAsync(function (a, b, cb) {
process.nextTick(function () {
cb(null, a + b);
});
}, {
maxAge: 1000
});
memoizedAsyncSum(1, 2, function () {
expect(memoizedAsyncSum.getAge(1, 2), 'to be a number');
done();
});
});
});
it('should keep serving the stale value after starting a refresh and not call the underlying method again', function () {
var nextNumber = 1,
method = sinon.spy(function getNextNumber(cb) {
setTimeout(function () {
cb(null, nextNumber);
nextNumber += 1;
}, 5);
}),
memoizedGetNextNumber = memoizeAsync(function (callback) {
method(callback);
}, { maxAge: 100, refreshAge: 50 });
return expect(memoizedGetNextNumber, 'to call the callback without error')
.spread(function (result) {
expect(result, 'to equal', 1);
expect(method, 'was called once');
})
.delay(55)
.then(function () {
return expect.promise.all([
expect(memoizedGetNextNumber, 'to call the callback without error'),
expect(memoizedGetNextNumber, 'to call the callback without error')
]);
})
.then(function (result) {
expect(result, 'to equal', [ [ 1 ], [ 1 ] ]);
expect(method, 'was called twice');
})
.delay(10)
.then(function () {
expect(memoizedGetNextNumber.peek(), 'to equal', [ null, 2 ]);
});
});
});