UNPKG

out-of-band-cache

Version:

generic cache and refreshing for api clients

304 lines (239 loc) 10.1 kB
/* eslint max-statements: 0 */ const path = require('path'); const assume = require('assume'); const Cache = require('../lib/'); const rimraf = require('rimraf'); const sinon = require('sinon'); function sleep(ms) { return new Promise(res => setTimeout(res, ms)); } const simpleGet = async i => i; describe('Out of Band cache', () => { const cachePath = path.resolve(__dirname, '../.cache'); beforeEach(done => { rimraf(cachePath, done); }); it('exposes persistence caches on the top level export', function () { assume(Cache.File).exists(); assume(Cache.LRU).exists(); assume(Cache.Memory).exists(); }); it('does not prevent a failed request from being retried later on', async function () { const cache = new Cache({ maxAge: 60 * 60 * 1000, fsCachePath: cachePath, maxStaleness: 60 * 60 * 1000 }); let value; async function willCrash() { await cache.get('some-key', {}, async (key, staleItem) => { assume(key).equals('some-key'); assume(staleItem).is.falsey(); throw new Error('Aaaa!'); }); } try { await willCrash(); } catch (e) { assume(e).matches(/Aaaa!/); } value = await cache.get('some-key', {}, async () => 'Some value'); assume(value).deep.equals({ fromCache: false, value: 'Some value' }); async function willAlsoCrash() { return await cache.get('some-key', {}, async () => { throw new Error('Got unexpected cache miss'); }); } try { value = await willAlsoCrash(); } catch (e) { assume(e).to.be.truthy(); } assume(value).deep.equals({ fromCache: true, value: 'Some value' }); }); it('always provides at least 1 cache', function () { const justMemory = new Cache({}); assume(justMemory._caches).has.length(1); const withFile = new Cache({ fsCachePath: cachePath }); assume(withFile._caches).has.length(2); }); it('uses an LRU cache if maxMemoryItems is set', async function () { const getter = sinon.spy(); const cache = new Cache({ maxMemoryItems: 2, fsCachePath: null }); await cache.get('a', {}, () => 1); await cache.get('b', {}, () => 2); await cache.get('c', {}, () => 3); await cache.get('c', {}, getter); assume(getter.called).is.falsy(); await cache.get('a', {}, getter); assume(getter.called).is.truthy(); }); describe('get', function () { it('never uses the cache if we specifically want to skip it', async function () { const skipper = new Cache({}); const first = await skipper.get('data', {}, simpleGet); const second = await skipper.get('data', { skipCache: true }, simpleGet); const third = await skipper.get('data', {}, simpleGet); assume(first.fromCache).is.falsey(); assume(second.fromCache).is.falsey(); assume(third.fromCache).is.truthy(); }); it('refreshes on a previously uncached key', async function () { const uncached = new Cache({}); uncached._refresh = async (key) => { assume(key).equals('data'); return true; }; const calledRefresh = await uncached.get('data', {}); assume(calledRefresh).is.truthy(); }); it('can override the cache-level maxAge', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale await fridge.get('good milk', { maxAge: 1000 }, async () => 'milk'); const milk = await fridge.get('good milk', { }, async () => { throw new Error('gross'); }); assume(milk.fromCache).is.truthy(); }); it('refreshes on an expired item', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale await fridge.get('old milk', {}, simpleGet); await sleep(100); const expired = await fridge.get('old milk', { }, simpleGet); assume(expired.fromCache).is.falsey(); }); it('refreshes on an expired item, but returns stale if within staleness', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: 1000 }); // items are immediately stale await fridge.get('old milk', {}, async () => 'milk'); await sleep(100); const expired = await fridge.get('old milk', { }, async () => { throw new Error('gross'); }); assume(expired.fromCache).is.truthy(); assume(expired.value).to.equal('milk'); }); it('refreshes on an expired item, but returns stale if infinite staleness', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: Infinity }); // items are immediately stale await fridge.get('old milk', {}, async () => 'milk'); await sleep(100); const expired = await fridge.get('old milk', { }, async () => { throw new Error('gross'); }); assume(expired.fromCache).is.truthy(); assume(expired.value).to.equal('milk'); }); it('can override the cache-level maxStaleness', async function () { const fridge = new Cache({ maxAge: -10, maxStaleness: -10 }); // items are immediately stale await fridge.get('old milk', {}, async () => 'milk'); await sleep(100); const expired = await fridge.get('old milk', { maxStaleness: 1000 }, async () => { throw new Error('gross'); }); assume(expired.fromCache).is.truthy(); assume(expired.value).to.equal('milk'); }); it('does not add an item to the cache if preconfigured not to', async function () { const dopey = new Cache({ shouldCache: () => false }); await dopey.get('diamond', {}, simpleGet); assume(dopey._caches[0]._items).has.length(0); }); it('does not add an item if we supply shouldCache as a get option', async function () { const dopey = new Cache({}); await dopey.get('diamond', { shouldCache: () => false }, simpleGet); assume(dopey._caches[0]._items).has.length(0); }); it('does not add an item if it fails shouldCache', async function () { const dopey = new Cache({ shouldCache: v => v !== 'words' }); // dopey doesn't know how to speak so he doesn't remember any words await dopey.get('sounds', {}, simpleGet); await dopey.get('words', {}, simpleGet); await dopey.get('diamonds', {}, simpleGet); assume(dopey._caches[0]._items).has.length(2); }); it('only sets the cache values with shouldCache is a function', async function () { let wonderland = new Cache({}); let errors = 0; // default await wonderland.get('tweedle-dee', {}, simpleGet); assume(wonderland._caches[0]._items).has.length(1); // boolean try { await wonderland.get('tweedle-dum', { shouldCache: true }, simpleGet); } catch (e) { assume(e).matches(/shouldCache has to be a function/); errors++; } assume(errors).equals(1); assume(wonderland._caches[0]._items).has.length(1); // string try { await wonderland.get('bandersnatch', { shouldCache: 'totally a function' }, simpleGet); } catch (e) { assume(e).matches(/shouldCache has to be a function/); errors++; } assume(errors).equals(2); assume(wonderland._caches[0]._items).has.length(1); // function await wonderland.get('jabberwocky', { shouldCache: () => true }, simpleGet); assume(wonderland._caches[0]._items).has.length(2); // in the constructor try { wonderland = new Cache({ shouldCache: 'definitely a function' }); } catch (e) { assume(e).matches(/shouldCache has to be a function/); errors++; } assume(errors).equals(3); }); it('does not set a cache item, even if we do it first', async function () { const eventually = new Cache({}); await eventually.get('not yet', { skipCache: () => false }, simpleGet); assume(eventually._caches[0]._items).has.length(0); await eventually.get('not yet', {}, simpleGet); await eventually.get('now', {}, simpleGet); assume(eventually._caches[0]._items).has.length(2); }); }); describe('_refresh', function () { it('immediately returns an existing pending refresh', async function () { const cache = new Cache({}); cache._pendingRefreshes.nuclearLaunchCodes = 12345; const stub = sinon.stub(); const value = await cache._refresh('nuclearLaunchCodes', null, stub, {}); assume(JSON.stringify(value)).equals(JSON.stringify({ value: 12345, fromCache: false })); assume(stub.called).is.falsey(); }); it('deletes a pending refresh if the getter crashes', async function () { const cache = new Cache({}); const badGetter = async () => { throw new Error('that was no bueno'); }; let caught; try { await cache._refresh('data', null, badGetter, {}); } catch (err) { caught = true; assume(err).matches('that was no bueno'); assume(cache._pendingRefreshes.data).does.not.exist(); } assume(caught).is.truthy(); }); it('gradually removes each pending request as they complete', async function () { const cache = new Cache({}); const slowGetter = async (i) => { await sleep(500); return i; }; const tasks = Array(10).fill(0).map((_, i) => { // no await because we want to proceed before they resolve return cache._refresh(i, null, slowGetter, {}); }); assume(Object.entries(cache._pendingRefreshes)).has.length(10); await Promise.all(tasks); assume(Object.entries(cache._pendingRefreshes)).has.length(0); }); }); describe('reset', function () { it('restores every cache', async function () { const resetti = new Cache({}); await resetti.get('save file', {}, simpleGet); await resetti.get('unsaved file', {}, simpleGet); assume(resetti._caches[0]._items).has.length(2); await resetti.reset(); assume(resetti._caches[0]._items).has.length(0); }); }); });