// TODO: These are really a mix of unit and integration tests.

var assert = require('assert');
var async = require('async');
var sinon = require('sinon');
var support = require('./support');
var checkErr = support.checkErr;
var caching = require('../index').caching;
var memoryStore = require('../lib/stores/memory');

var Promise = require('es6-promise').Promise;

var methods = {
    getWidget: function(name, cb) {
        process.nextTick(function() {
            cb(null, {name: name});
        });
    },
    getMultiWidget: function(names, cb) {
        process.nextTick(function() {
            cb(null, names.map(function(name) { return {name: name}; }));
        });
    },
};

describe("caching", function() {
    var cache;
    var key;
    var defaultTtl = 1;
    var name;
    var value;

    describe("get() and set()", function() {
        ['memory'].forEach(function(store) {
            context("using " + store + " store", function() {
                beforeEach(function() {
                    cache = caching({store: store, promiseDependency: Promise});
                    key = support.random.string(20);
                    value = support.random.string();
                });

                it("lets us set and get data in cache", function(done) {
                    cache.set(key, value, {ttl: defaultTtl}, function(err) {
                        checkErr(err);

                        cache.get(key, function(err, result) {
                            checkErr(err);
                            assert.equal(result, value);
                            done();
                        });
                    });
                });

                it("lets us set and get data without a callback", function(done) {
                    cache = caching({store: memoryStore.create({noPromises: true})});
                    cache.set(key, value, {ttl: defaultTtl});

                    setTimeout(function() {
                        var result = cache.get(key);
                        assert.equal(result, value);
                        done();
                    }, 20);
                });

                it("lets us set and get data without a callback, returning a promise", function(done) {
                    cache.set(key, value, {ttl: defaultTtl});
                    setTimeout(function() {
                        cache.get(key)
                        .then(function(result) {
                            assert.equal(result, value);
                            done();
                        });
                    }, 20);
                });

                it("lets us set and get data without options object or callback", function(done) {
                    cache = caching({store: memoryStore.create({noPromises: true})});
                    cache.set(key, value);

                    setTimeout(function() {
                        var result = cache.get(key);
                        assert.equal(result, value);
                        done();
                    }, 20);
                });
            });
        });
    });

    describe("mget() and mset()", function() {
        var key2;
        var value2;
        var store = 'memory';

        beforeEach(function() {
            key = support.random.string(20);
            value = support.random.string();
            key2 = support.random.string(20);
            value2 = support.random.string();

            cache = caching({store: store, ttl: defaultTtl, ignoreCacheErrors: false});
        });

        it("lets us set and get several keys and data in cache", function(done) {
            cache.mset(key, value, key2, value2, {ttl: defaultTtl}, function(err) {
                checkErr(err);

                cache.mget(key, key2, function(err, result) {
                    checkErr(err);
                    assert.equal(result[0], value);
                    assert.equal(result[1], value2);
                    done();
                });
            });
        });

        it("lets us set and get data without a callback", function(done) {
            cache = caching({store: memoryStore.create({noPromises: true})});
            cache.mset(key, value, key2, value2, {ttl: defaultTtl});

            setTimeout(function() {
                var result = cache.mget(key, key2);
                assert.equal(result[0], value);
                assert.equal(result[1], value2);
                done();
            }, 20);
        });

        it("lets us set and get data without a callback, returning a promise", function(done) {
            cache.mset(key, value, key2, value2, {ttl: defaultTtl});
            setTimeout(function() {
                cache.mget(key, key2)
                .then(function(result) {
                    assert.equal(result[0], value);
                    assert.equal(result[1], value2);
                    done();
                });
            }, 20);
        });

        it("lets us set and get data without options object or callback", function(done) {
            cache = caching({store: memoryStore.create({noPromises: true})});
            cache.mset(key, value, key2, value2);

            setTimeout(function() {
                var result = cache.mget(key, key2);
                assert.equal(result[0], value);
                assert.equal(result[1], value2);
                done();
            }, 20);
        });

        it("lets us pass an 'options' object", function(done) {
            cache = caching({store: memoryStore.create({noPromises: true})});
            cache.mset(key, value, key2, value2);

            setTimeout(function() {
                var result = cache.mget(key, key2, {someConfig: true});
                assert.equal(result[0], value);
                assert.equal(result[1], value2);
                done();
            }, 20);
        });
    });

    describe("del()", function() {
        ['memory'].forEach(function(store) {
            context("using " + store + " store", function() {
                beforeEach(function(done) {
                    cache = caching({store: store});
                    key = support.random.string(20);
                    value = support.random.string();
                    cache.set(key, value, {ttl: defaultTtl}, function(err) {
                        checkErr(err);
                        done();
                    });
                });

                it("deletes data from cache", function(done) {
                    cache.get(key, function(err, result) {
                        assert.equal(result, value);

                        cache.del(key, function(err) {
                            checkErr(err);

                            cache.get(key, function(err, result) {
                                assert.ok(!result);
                                done();
                            });
                        });
                    });
                });

                it("lets us delete data without a callback", function(done) {
                    cache.get(key, function(err, result) {
                        assert.equal(result, value);

                        cache.del(key);

                        setTimeout(function() {
                            cache.get(key, function(err, result) {
                                assert.ok(!result);
                                done();
                            });
                        }, 20);
                    });
                });

                it("lets us delete data using promises", function(done) {
                    key = support.random.string();
                    cache.set(key, value)
                    .then(function() {
                        return cache.get(key);
                    }).then(function(val) {
                        assert.equal(val, value);
                    }).then(function() {
                        return cache.del(key);
                    }).then(function() {
                        return cache.get(key);
                    }).then(function(val) {
                        assert.strictEqual(val, undefined);
                    }).then(done)
                    .catch(function(err) {
                        done(err);
                    });
                });

                describe('with multiple keys', function() {
                    var key2;
                    var value2;

                    beforeEach(function(done) {
                        cache = caching({store: store});
                        key2 = support.random.string(20);
                        value2 = support.random.string();
                        cache.mset(key, value, key2, value2, {ttl: defaultTtl}, function(err) {
                            checkErr(err);
                            done();
                        });
                    });

                    it('deletes an unlimited number of key arguments', function(done) {
                        cache.mget(key, key2, function(err, result) {
                            assert.equal(result[0], value);
                            assert.equal(result[1], value2);

                            cache.del(key, key2, function(err) {
                                checkErr(err);

                                cache.mget(key, key2, function(err, result) {
                                    assert.ok(!result[0]);
                                    assert.ok(!result[1]);
                                    done();
                                });
                            });
                        });
                    });

                    it('deletes an array of keys', function(done) {
                        cache.mget(key, key2, function(err, result) {
                            assert.equal(result[0], value);
                            assert.equal(result[1], value2);

                            cache.del([key, key2], function(err) {
                                checkErr(err);

                                cache.mget(key, key2, function(err, result) {
                                    assert.ok(!result[0]);
                                    assert.ok(!result[1]);
                                    done();
                                });
                            });
                        });
                    });
                });
            });
        });
    });

    describe("reset()", function() {
        var key2;
        var value2;

        beforeEach(function(done) {
            cache = caching({store: 'memory'});
            key = support.random.string(20);
            value = support.random.string();
            cache.set(key, value, function(err) {
                checkErr(err);

                key2 = support.random.string(20);
                value2 = support.random.string();

                cache.set(key2, value2, done);
            });
        });

        it("clears the cache", function(done) {
            cache.reset(function(err) {
                checkErr(err);

                cache.get(key, function(err, result) {
                    assert.ok(!result);

                    cache.get(key2, function(err, result) {
                        assert.ok(!result);
                        done();
                    });
                });
            });
        });

        it("lets us clear the cache without a callback (memory store only)", function(done) {
            cache.reset();
            setTimeout(function() {
                cache.get(key, function(err, result) {
                    assert.ok(!result);

                    cache.get(key2, function(err, result) {
                        assert.ok(!result);
                        done();
                    });
                });
            }, 10);
        });

        context("when store has no del(), mget() or mset() method", function() {
            var fakeStore;

            beforeEach(function() {
                fakeStore = {
                    get: function() {},
                    set: function() {},
                };
            });

            it("it doesn't throw an error", function() {
                assert.doesNotThrow(function() {
                    caching({store: fakeStore});
                });
            });
        });
    });

    describe("setex()", function() {
        var fakeStore;

        beforeEach(function() {
            fakeStore = {
                get: function() {},
                set: function() {},
                del: function() {},
                setex: function() {}
            };

            sinon.stub(fakeStore, 'setex');

            cache = caching({store: fakeStore});
        });

        it("passes the params to the underlying store's setex() method", function() {
            cache.setex('foo', 'bar', 'blah');
            assert.ok(fakeStore.setex.calledWith('foo', 'bar', 'blah'));
        });
    });

    describe("ttl()", function() {
        var fakeStore;

        beforeEach(function() {
            fakeStore = {
                get: function() {},
                set: function() {},
                del: function() {},
                ttl: function() {}
            };

            sinon.stub(fakeStore, 'ttl');

            cache = caching({store: fakeStore});
        });

        it("passes the params to the underlying store's ttl() method", function() {
            cache.ttl('foo');
            assert.ok(fakeStore.ttl.calledWith('foo'));
        });
    });

    describe("keys()", function() {
        var keyCount;
        var savedKeys;

        beforeEach(function(done) {
            keyCount = 10;
            savedKeys = [];
            var processed = 0;

            cache = caching({store: 'memory'});

            function isDone() {
                return processed === keyCount;
            }

            async.until(isDone, function(cb) {
                processed += 1;
                key = support.random.string(20);
                savedKeys.push(key);
                value = support.random.string();
                cache.set(key, value, cb);
            }, done);
        });

        it("calls back with all keys in cache", function(done) {
            cache.keys(function(err, keys) {
                checkErr(err);
                assert.deepEqual(keys.sort(), savedKeys.sort());
                done();
            });
        });

        it("lets us set and get data without a callback, returning a promise", function(done) {
            cache.keys()
            .then(function(keys) {
                assert.deepEqual(keys.sort(), savedKeys.sort());
                done();
            })
            .catch(function(err) {
                done(err);
            });
        });

        context("when not using promises", function() {
            beforeEach(function(done) {
                savedKeys = [];
                keyCount = 10;
                var processed = 0;

                cache = caching({store: memoryStore.create({noPromises: true})});

                function isDone() {
                    return processed === keyCount;
                }

                async.until(isDone, function(cb) {
                    processed += 1;
                    key = support.random.string(20);
                    savedKeys.push(key);
                    value = support.random.string();
                    cache.set(key, value, cb);
                }, done);
            });

            it("lets us get the keys without a callback (memory store only)", function() {
                var keys = cache.keys();
                assert.deepEqual(keys.sort(), savedKeys.sort());
            });
        });
    });

    describe("wrap()", function() {
        describe("using memory (lru-cache) store", function() {
            var memoryStoreStub;
            var opts;

            beforeEach(function() {
                opts = {ttl: 0.1};
                memoryStoreStub = memoryStore.create(opts);

                sinon.stub(memoryStore, 'create').returns(memoryStoreStub);

                cache = caching({store: 'memory', ttl: opts.ttl, ignoreCacheErrors: false});
                key = support.random.string(20);
                name = support.random.string();
            });

            afterEach(function() {
                memoryStore.create.restore();
            });

            context("calls back with the result of the wrapped function", function() {
                beforeEach(function() {
                    sinon.spy(memoryStoreStub, 'set');
                });

                afterEach(function() {
                    memoryStoreStub.set.restore();
                });

                it("when a ttl is passed in", function(done) {
                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, cb);
                    }, opts, function(err, widget) {
                        checkErr(err);
                        assert.deepEqual(widget, {name: name});
                        sinon.assert.calledWith(memoryStoreStub.set, key, {name: name}, opts);
                        done();
                    });
                });

                it("when a ttl is not passed in", function(done) {
                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, cb);
                    }, function(err, widget) {
                        checkErr(err);
                        assert.deepEqual(widget, {name: name});
                        sinon.assert.calledWith(memoryStoreStub.set, key, {name: name}, {});
                        done();
                    });
                });
            });

            context("ttl function", function() {
                beforeEach(function() {
                    sinon.spy(memoryStoreStub, 'set');
                });

                afterEach(function() {
                    memoryStoreStub.set.restore();
                });

                it("when a ttl function is passed in", function(done) {
                    opts = {
                        ttl: function(widget) {
                            assert.deepEqual(widget, {name: name});
                            return 0.2;
                        }
                    };
                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, cb);
                    }, opts, function(err, widget) {
                        checkErr(err);
                        assert.deepEqual(widget, {name: name});
                        sinon.assert.calledWith(memoryStoreStub.set, key, {name: name}, {ttl: 0.2});
                        done();
                    });
                });
            });

            context("when result is already cached", function() {
                function getCachedWidget(name, cb) {
                    cache.wrap(key, function(cacheCb) {
                        methods.getWidget(name, cacheCb);
                    }, opts, cb);
                }

                beforeEach(function(done) {
                    getCachedWidget(name, function(err, widget) {
                        checkErr(err);
                        assert.ok(widget);

                        memoryStoreStub.get(key, function(err, result) {
                            checkErr(err);
                            assert.ok(result);

                            sinon.spy(memoryStoreStub, 'get');

                            done();
                        });
                    });
                });

                afterEach(function() {
                    memoryStoreStub.get.restore();
                });

                it("retrieves data from cache", function(done) {
                    var funcCalled = false;

                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, function(err, result) {
                            funcCalled = true;
                            cb(err, result);
                        });
                    }, function(err, widget) {
                        checkErr(err);
                        assert.deepEqual(widget, {name: name});
                        assert.ok(memoryStoreStub.get.calledWith(key));
                        assert.ok(!funcCalled);
                        done();
                    });
                });
            });

            var falseyValues = [false, null, 0];

            falseyValues.forEach(function(falseyValue) {
                context("when cached value is `" + falseyValue + "`", function() {
                    function getFalseyValue(cb) {
                        process.nextTick(function() {
                            cb(null, falseyValue);
                        });
                    }

                    function getCachedFalseyValue(cb) {
                        cache.wrap(key, function(cacheCb) {
                            getFalseyValue(cacheCb);
                        }, cb);
                    }

                    beforeEach(function(done) {
                        getCachedFalseyValue(function(err, result) {
                            checkErr(err);
                            assert.strictEqual(result, falseyValue);

                            memoryStoreStub.get(key, function(err, result) {
                                checkErr(err);
                                assert.strictEqual(result, falseyValue);

                                sinon.spy(memoryStoreStub, 'get');

                                done();
                            });
                        });
                    });

                    afterEach(function() {
                        memoryStoreStub.get.restore();
                    });

                    it("retrieves data from cache", function(done) {
                        getCachedFalseyValue(function(err, value) {
                            checkErr(err);
                            assert.strictEqual(value, falseyValue);
                            assert.ok(memoryStoreStub.get.calledWith(key));
                            done();
                        });
                    });
                });
            });

            context("when we pass in an isCacheableValue function to the caching constructor", function() {
                var testCallbacks = {
                    isCacheableValue: function(value) {
                        return value !== 'do_not_store_this' && value !== undefined;
                    }
                };

                function getValue(name, cb) {
                    process.nextTick(function() {
                        if (name === 'foo') {
                            cb(null, 'store_this');
                        } else {
                            cb(null, 'do_not_store_this');
                        }
                    });
                }

                function getCachedValue(name, cb) {
                    cache.wrap(key, function(cacheCb) {
                        getValue(name, function(err, result) {
                            cacheCb(err, result);
                        });
                    }, {ttl: defaultTtl}, cb);
                }

                beforeEach(function() {
                    sinon.spy(testCallbacks, 'isCacheableValue');
                    cache = caching({store: 'memory', isCacheableValue: testCallbacks.isCacheableValue});
                    sinon.spy(memoryStoreStub, 'set');
                });

                afterEach(function() {
                    memoryStoreStub.set.restore();
                    testCallbacks.isCacheableValue.restore();
                });

                it("stores allowed values", function(done) {
                    var name = 'foo';

                    getCachedValue(name, function(err) {
                        checkErr(err);
                        assert.ok(memoryStoreStub.set.called);
                        assert.ok(testCallbacks.isCacheableValue.called);

                        getCachedValue(name, function(err) {
                            checkErr(err);
                            done();
                        });
                    });
                });

                it("does not store non-allowed values", function(done) {
                    var name = 'bar';

                    getCachedValue(name, function(err) {
                        checkErr(err);
                        assert.ok(memoryStoreStub.set.notCalled);
                        assert.ok(testCallbacks.isCacheableValue.called);
                        done();
                    });
                });
            });

            context("when callback called twice by client", function() {
                it("it does not throw an error", function(done) {
                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, cb);
                        methods.getWidget(name, cb);
                    }, opts, function(err, widget) {
                        checkErr(err);
                        assert.deepEqual(widget, {name: name});
                        setTimeout(done, 10);
                    });
                });
            });

            it("lets us make nested calls", function(done) {
                function getCachedWidget(name, cb) {
                    cache.wrap(key, function(cacheCb) {
                        methods.getWidget(name, cacheCb);
                    }, cb);
                }

                getCachedWidget(name, function(err, widget) {
                    checkErr(err);
                    assert.equal(widget.name, name);

                    getCachedWidget(name, function(err, widget) {
                        checkErr(err);
                        assert.equal(widget.name, name);

                        getCachedWidget(name, function(err, widget) {
                            checkErr(err);
                            assert.equal(widget.name, name);
                            done();
                        });
                    });
                });
            });

            it("expires cached result after ttl seconds", function(done) {
                var ttl = 0.1;

                cache.wrap(key, function(cb) {
                    methods.getWidget(name, cb);
                }, {ttl: ttl}, function(err, widget) {
                    checkErr(err);
                    assert.deepEqual(widget, {name: name});

                    memoryStoreStub.get(key, function(err, result) {
                        checkErr(err);
                        assert.ok(result);

                        var funcCalled = false;

                        setTimeout(function() {
                            cache.wrap(key, function(cb) {
                                methods.getWidget(name, function(err, result) {
                                    funcCalled = true;
                                    cb(err, result);
                                });
                            }, function(err, widget) {
                                checkErr(err);
                                assert.ok(funcCalled);
                                assert.deepEqual(widget, {name: name});
                                done();
                            });
                        }, (ttl * 1000 + 10));
                    });
                });
            });

            context("when an error is thrown in the work function", function() {
                var fakeError;

                beforeEach(function() {
                    fakeError = new Error(support.random.string());
                });

                it("does not catch the error", function(done) {
                    var originalExceptionHandler = process.listeners('uncaughtException').pop();
                    process.removeListener('uncaughtException', originalExceptionHandler);

                    process.once('uncaughtException', function(err) {
                        process.on('uncaughtException', originalExceptionHandler);
                        assert.ok(err);
                        done();
                    });

                    cache.wrap(key, function() {
                        throw fakeError;
                    }, function() {
                        done(new Error('Should not have caught error'));
                    });
                });
            });

            context("when store.get() calls back with an error", function() {
                context("and ignoreCacheErrors is not set (default is false)", function() {
                    it("bubbles up that error", function(done) {
                        var fakeError = new Error(support.random.string());

                        sinon.stub(memoryStoreStub, 'get', function(key, options, cb) {
                            cb(fakeError);
                        });

                        cache.wrap(key, function(cb) {
                            methods.getWidget(name, cb);
                        }, function(err) {
                            assert.equal(err, fakeError);
                            memoryStoreStub.get.restore();
                            done();
                        });
                    });
                });

                context("and ignoreCacheErrors is set to true", function() {
                    it("does not bubble up that error", function(done) {
                        cache = caching({store: 'memory', ttl: defaultTtl, ignoreCacheErrors: true});

                        var fakeError = new Error(support.random.string());

                        sinon.stub(memoryStoreStub, 'get', function(key, options, cb) {
                            cb(fakeError);
                        });

                        cache.wrap(key, function(cb) {
                            methods.getWidget(name, cb);
                        }, function(err) {
                            assert.equal(err, null);
                            memoryStoreStub.get.restore();
                            done();
                        });
                    });
                });
            });

            context("when store.set() calls back with an error", function() {
                context("and ignoreCacheErrors is not set", function() {
                    it("bubbles up that error", function(done) {
                        var fakeError = new Error(support.random.string());

                        sinon.stub(memoryStoreStub, 'set', function(key, val, ttl, cb) {
                            cb(fakeError);
                        });

                        cache.wrap(key, function(cb) {
                            methods.getWidget(name, cb);
                        }, function(err) {
                            assert.equal(err, fakeError);
                            memoryStoreStub.set.restore();
                            done();
                        });
                    });
                });

                context("and ignoreCacheErrors is set to true", function() {
                    it("does not bubbles up that error", function(done) {
                        cache = caching({store: 'memory', ttl: defaultTtl, ignoreCacheErrors: true});
                        var fakeError = new Error(support.random.string());

                        sinon.stub(memoryStoreStub, 'set').yields(fakeError);

                        cache.wrap(key, function(cb) {
                            methods.getWidget(name, cb);
                        }, function(err) {
                            assert.equal(err, null);
                            memoryStoreStub.set.restore();
                            done();
                        });
                    });
                });
            });

            context("when wrapped function calls back with an error", function() {
                it("calls back with that error", function(done) {
                    var fakeError = new Error(support.random.string());
                    sinon.stub(methods, 'getWidget', function(name, cb) {
                        cb(fakeError, {name: name});
                    });

                    cache.wrap(key, function(cb) {
                        methods.getWidget(name, cb);
                    }, function(err, widget) {
                        methods.getWidget.restore();
                        assert.equal(err, fakeError);
                        assert.ok(!widget);
                        done();
                    });
                });
            });

            context("when wrapped function returns a non cacheable value once", function() {
                it("second call to 'wrap' triggers the callback", function(done) {
                    var key = support.random.string();

                    // 1.
                    cache.wrap(key, function(cb) {
                        cb(null, undefined);
                    }, function(err, result) {
                        assert.equal(result, undefined);
                    });

                    // 2.
                    cache.wrap(key, function(cb) {
                        cb(null, undefined);
                    }, function(err, result) {
                        assert.equal(result, undefined);
                        done();
                    });
                });
            });

            context("when passing multiple keys", function() {
                var key2;
                var name2;

                beforeEach(function() {
                    key2 = '2-' + support.random.string(20);
                    name2 = '2-' + support.random.string();
                    sinon.spy(memoryStoreStub, 'mset');
                });

                afterEach(function() {
                    memoryStoreStub.mset.restore();
                });

                context("when no keys are already cached", function() {
                    it("retrieves data from data store and caches results via mset", function(done) {
                        var funcCalled = false;
                        sinon.stub(memoryStoreStub, 'mget').yields();

                        cache.wrap(key, key2, function(cb) {
                            funcCalled = true;
                            cb(null, [{name: name}, {name: name2}]);
                        }, function(err) {
                            checkErr(err);
                            assert.ok(memoryStoreStub.mget.calledWith(key, key2));
                            assert.ok(funcCalled);
                            assert.ok(memoryStoreStub.mset.called);
                            var callArgs = memoryStoreStub.mset.args[0];
                            assert.equal(callArgs[0], key);
                            assert.deepEqual(callArgs[1], {name: name});
                            assert.equal(callArgs[2], key2);
                            assert.deepEqual(callArgs[3], {name: name2});
                            memoryStoreStub.mget.restore();
                            done();
                        });
                    });
                });

                context("when result is already cached", function() {
                    it("retrieves data from cache", function(done) {
                        var funcCalled = false;
                        sinon.stub(memoryStoreStub, 'mget', function() {
                            var args = Array.prototype.slice.apply(arguments);
                            var cb = args.pop();
                            cb(null, [{name: name}, {name: name2}]);
                        });

                        cache.wrap(key, key2, function(cb) {
                            funcCalled = true;
                            cb();
                        }, function(err, widgets) {
                            checkErr(err);
                            assert.deepEqual(widgets[0], {name: name});
                            assert.deepEqual(widgets[1], {name: name2});
                            assert.ok(memoryStoreStub.mget.calledWith(key, key2));
                            assert.ok(!funcCalled);
                            memoryStoreStub.mget.restore();
                            done();
                        });
                    });
                });

                context("when some keys are already cached but others are not", function() {
                    // This means the user must manually call the data store to get any
                    // values that are not in cache, which is not ideal.
                    it("responds with only the data that exists in the cache", function(done) {
                        var key3 = support.random.string();
                        var name3 = support.random.string();
                        var funcCalled = false;

                        sinon.stub(memoryStoreStub, 'mget').yields(null, [{name: name}, {name: name3}]);

                        cache.wrap(key, key2, key3, function(cb) {
                            funcCalled = true;
                            methods.getMultiWidget([name2], cb);
                        }, function(err, widgets) {
                            checkErr(err);
                            assert.deepEqual(widgets[0], {name: name});
                            assert.deepEqual(widgets[1], {name: name3});
                            assert.ok(memoryStoreStub.mget.calledWith(key, key2));
                            assert.ok(!funcCalled);
                            memoryStoreStub.mget.restore();
                            done();
                        });
                    });
                });

                it("when a ttl is passed in", function(done) {
                    cache.wrap(key, key2, function(cb) {
                        methods.getMultiWidget([name, name2], cb);
                    }, opts, function(err, widgets) {
                        checkErr(err);
                        assert.deepEqual(widgets[0], {name: name});
                        assert.deepEqual(widgets[1], {name: name2});
                        sinon.assert.calledWith(memoryStoreStub.mset, key, {name: name}, key2, {name: name2}, opts);
                        done();
                    });
                });

                it("when a ttl is passed in (function)", function(done) {
                    var ttlFunc = function() { return 1234; };
                    opts = {ttl: ttlFunc};

                    cache.wrap(key, key2, function(cb) {
                        methods.getMultiWidget([name, name2], cb);
                    }, opts, function(err) {
                        checkErr(err);
                        sinon.assert.calledWith(
                            memoryStoreStub.mset, key, {name: name}, key2, {name: name2}, {ttl: 1234}
                        );
                        done();
                    });
                });

                it("when a ttl is not passed in", function(done) {
                    cache.wrap(key, key2, function(cb) {
                        methods.getMultiWidget([name, name2], cb);
                    }, function(err, widgets) {
                        checkErr(err);
                        assert.deepEqual(widgets[0], {name: name});
                        assert.deepEqual(widgets[1], {name: name2});
                        sinon.assert.calledWith(memoryStoreStub.mset, key, {name: name}, key2, {name: name2}, {});
                        done();
                    });
                });

                it("does not store non-allowed values", function(done) {
                    var name = 'bar';

                    cache = caching({store: 'memory', isCacheableValue: function() { return false; }});
                    cache.wrap(key, key2, function(cb) {
                        methods.getMultiWidget([name, name2], cb);
                    }, function(err) {
                        checkErr(err);
                        assert.ok(memoryStoreStub.mset.notCalled);
                        done();
                    });
                });

                context("when store.mget() calls back with an error", function() {
                    context("and ignoreCacheErrors is not set (default is false)", function() {
                        it("bubbles up that error", function(done) {
                            var fakeError = new Error(support.random.string());

                            sinon.stub(memoryStoreStub, 'mget', function() {
                                var args = Array.prototype.slice.apply(arguments);
                                var cb = args.pop();
                                cb(fakeError);
                            });

                            cache.wrap(key, key2, function(cb) {
                                methods.getMultiWidget([name, name2], cb);
                            }, function(err) {
                                assert.equal(err, fakeError);
                                memoryStoreStub.mget.restore();
                                done();
                            });
                        });
                    });

                    context("and ignoreCacheErrors is set to true", function() {
                        it("does not bubble up that error", function(done) {
                            cache = caching({store: 'memory', ttl: defaultTtl, ignoreCacheErrors: true});

                            var fakeError = new Error(support.random.string());

                            sinon.stub(memoryStoreStub, 'mget', function() {
                                var args = Array.prototype.slice.apply(arguments);
                                var cb = args.pop();
                                cb(fakeError);
                            });

                            cache.wrap(key, key2, function(cb) {
                                methods.getMultiWidget([name, name2], cb);
                            }, function(err) {
                                assert.equal(err, null);
                                memoryStoreStub.mget.restore();
                                done();
                            });
                        });
                    });
                });

                context("when an error is thrown in the work function", function() {
                    var fakeError;

                    beforeEach(function() {
                        fakeError = new Error(support.random.string());
                    });

                    it("does catch the error ", function(done) {
                        cache = caching({store: 'memory', ttl: defaultTtl, ignoreCacheErrors: false});

                        cache.wrap(key, key2, function(cb) {
                            return cb(fakeError);
                        }, function(err) {
                            assert.equal(err, fakeError);
                            done();
                        });
                    });
                });
            });
        });

        describe("when called multiple times in parallel with same key", function() {
            var construct;
            var constructMultiple;

            beforeEach(function() {
                cache = caching({
                    store: 'memory',
                    max: 50,
                    ttl: 5 * 60
                });

                construct = sinon.spy(function(val, cb) {
                    var timeout = support.random.number(100);
                    setTimeout(function() {
                        cb(null, 'value');
                    }, timeout);
                });

                constructMultiple = sinon.spy(function(val, cb) {
                    var timeout = support.random.number(100);
                    setTimeout(function() {
                        cb(null, ['value', 'value2']);
                    }, timeout);
                });
            });

            it("calls the wrapped function once", function(done) {
                var values = [];
                for (var i = 0; i < 2; i++) {
                    values.push(i);
                }

                async.each(values, function(val, next) {
                    cache.wrap('key', function(cb) {
                        construct(val, cb);
                    }, function(err, result) {
                        assert.equal(result, 'value');
                        next(err);
                    });
                }, function(err) {
                    checkErr(err);
                    assert.equal(construct.callCount, 1);
                    done();
                });
            });

            it("calls the multiWrapped function once", function(done) {
                var values = [];
                for (var i = 0; i < 2; i++) {
                    values.push(i);
                }

                async.each(values, function(val, next) {
                    cache.wrap('key', 'key2', function(cb) {
                        constructMultiple(val, cb);
                    }, function(err, results) {
                        assert.equal(results[0], 'value');
                        assert.equal(results[1], 'value2');
                        next(err);
                    });
                }, function(err) {
                    checkErr(err);
                    assert.equal(constructMultiple.callCount, 1);
                    done();
                });
            });
        });

        describe("using native promises", function() {
            beforeEach(function() {
                cache = caching({
                    store: 'memory',
                    max: 50,
                    ttl: 5 * 60
                });
            });

            it("should be able to chain with simple promise", function(done) {
                cache.wrap('key', function() {
                    return 'OK';
                })
                .then(function(res) {
                    assert.equal(res, 'OK');
                    done();
                });
            });

            it("should be able to chain with cache function as a promise", function(done) {
                cache.wrap('key', function() {
                    return new Promise(function(resolve) {
                        resolve('OK');
                    });
                })
                .then(function(res) {
                    assert.equal(res, 'OK');
                    done();
                });
            });

            it("should be able to catch errors in cache function as a promise", function(done) {
                cache.wrap('key', function() {
                    return new Promise(function(resolve, reject) {
                        reject('NOK');
                    });
                })
                .then(function() {
                    done(new Error('It should not call then since there is an error in the cache function!'));
                })
                .catch(function() {
                    done();
                });
            });

            it("should be able to chain with non-cacheable value", function(done) {
                cache.wrap('key', function() {
                    return;
                })
                .then(function(res) {
                    assert.equal(res, undefined);
                    done();
                });
            });
        });
    });

    describe("instantiating with no store passed in", function() {
        it("defaults to 'memory' store", function() {
            var cache = caching();
            assert.equal(cache.store.name, 'memory');
        });
    });

    describe("instantiating with custom store", function() {
        it("allows us to pass in our own store object", function(done) {
            var store = memoryStore.create({ttl: defaultTtl});
            cache = caching({store: store});
            cache.set(key, value, function(err) {
                checkErr(err);
                cache.get(key, function(err, result) {
                    assert.equal(result, value);
                    done();
                });
            });
        });

        it("allows us to pass in a module (uninstantiated)", function(done) {
            var store = memoryStore;
            cache = caching({store: store});
            cache.set(key, value, {ttl: defaultTtl}, function(err) {
                checkErr(err);
                cache.get(key, function(err, result) {
                    assert.equal(result, value);
                    done();
                });
            });
        });
    });

    describe("overloading with custom store", function() {
        it("allows us to override isCacheableValue", function(done) {
            var store = memoryStore.create({ttl: defaultTtl});
            var onlyOne = true;
            store.isCacheableValue = function(result) {
                if (onlyOne) {
                    onlyOne = false;
                    done();
                }
                return result !== undefined;
            };
            cache = caching({store: store});
            cache.wrap(key, function(cb) {
                methods.getWidget(name, cb);
            }, function(err, widget) {
                checkErr(err);
                assert.deepEqual(widget, {name: name});
            });
        });
    });

    describe("when using store's isCacheableValue method", function() {
        it("should not break its' context", function() {
            var store = {
                isCacheableValue: function() {
                    if (this !== store) {
                        throw new Error("Broken store context");
                    }
                },
                get: function() {},
                set: function() {},
            };

            cache = caching({store: store});

            assert.doesNotThrow(cache._isCacheableValue);
        });
    });
});
