apicache-plus
Version:
Effortless api response caching for Express/Node using plain-english durations
1,510 lines (1,389 loc) • 148 kB
JavaScript
/* eslint-disable no-unused-expressions */
var chai = require('chai')
var expect = chai.expect
var express = require('express')
var request = require('supertest')
var pkg = require('../package.json')
var movies = require('./api/lib/data.json')
var redis = require('ioredis-mock')
var apicache = require('../src/apicache')
var helpers = require('../src/helpers')
var isKoa = helpers.isKoa
// node-redis usage
redis.createClient = function(options) {
if (options.prefix) options.keyPrefix = options.prefix
var client = new this(options)
// patch append to work with buffers and add missing getrangeBuffer
var multi = client.multi()
client.multi = function() {
return multi
}
var multiAppend = multi.append.bind(multi)
multi.append = function(key, value) {
if (!Buffer.isBuffer(value)) return multiAppend(key, value)
var memo = client.data.get(key) || Buffer.alloc(0)
value = Buffer.from(value, 'utf8') // ioredis-mock stores buffers as utf-8
client.data.set(key, Buffer.concat([memo, value]))
}
client.getrangeBuffer = client.getrange
return client
}
// unexpected order
var revertedCompressionApis = [
{ name: 'express+gzip (after)', server: require('./api/express-gzip-after') },
{ name: 'restify+gzip (after)', server: require('./api/restify-gzip-after') },
// as we currently don't manipulate ctx.body (we patch res.write/end),
// this regular one acts like a reverted compression api (apicache will receive an already compressed stream)
{ name: 'koa+compression (after)', server: require('./api/koa-compression') },
]
var compressionApis = [
{ name: 'express+gzip', server: require('./api/express-gzip') },
{ name: 'restify+gzip', server: require('./api/restify-gzip') },
{ name: 'koa+compression', server: require('./api/koa-compression') },
]
var regularApis = [
{ name: 'express', server: require('./api/express') },
{ name: 'restify', server: require('./api/restify') },
{ name: 'koa', server: require('./api/koa') },
]
var apis = regularApis.concat(compressionApis)
function assertNumRequestsProcessed(app, n) {
return function() {
expect(app.requestsProcessed).to.equal(n)
}
}
var _setTimeout = global.setTimeout
var timeouts = []
global.setTimeout = function() {
var timeout = _setTimeout.apply(null, arguments)
timeouts.push(timeout)
return timeout
}
function clearAllTimeouts(cb) {
while (timeouts.length) clearTimeout(timeouts.pop())
cb()
}
afterEach(function(done) {
clearAllTimeouts(done)
})
describe('.options(opt?) {GETTER/SETTER}', function() {
var apicache = require('../src/apicache')
it('is a function', function() {
expect(typeof apicache.options).to.equal('function')
})
describe('.options() {GETTER}', function() {
it('returns global options as object', function() {
expect(typeof apicache.options()).to.equal('object')
})
})
describe('.options(opt) {SETTER}', function() {
it('is chainable', function() {
expect(apicache.options({})).to.equal(apicache)
})
it('extends defaults', function() {
expect(apicache.options({ foo: 'bar' }).options().foo).to.equal('bar')
})
it('allows overrides of defaults', function() {
var newDuration = 11
expect(apicache.options()).to.have.property('defaultDuration')
expect(apicache.options({ defaultDuration: newDuration }).options().defaultDuration).to.equal(
newDuration
)
})
})
})
describe('.getDuration(stringOrNumber) {GETTER}', function() {
var apicache = require('../src/apicache')
it('is a function', function() {
expect(typeof apicache.getDuration).to.equal('function')
})
it('returns value unchanged if numeric', function() {
expect(apicache.getDuration(77)).to.equal(77)
})
it('returns default duration when uncertain', function() {
apicache.options({ defaultDuration: 999 })
expect(apicache.getDuration(undefined)).to.equal(999)
})
it('accepts singular or plural (e.g. "1 hour", "3 hours")', function() {
expect(apicache.getDuration('3 seconds')).to.equal(3000)
expect(apicache.getDuration('3 second')).to.equal(3000)
})
it('accepts decimals (e.g. "1.5 hours")', function() {
expect(apicache.getDuration('1.5 seconds')).to.equal(1500)
})
describe('unit support', function() {
it('numeric values as milliseconds', function() {
expect(apicache.getDuration(43)).to.equal(43)
})
it('milliseconds', function() {
expect(apicache.getDuration('3 ms')).to.equal(3)
})
it('seconds', function() {
expect(apicache.getDuration('3 seconds')).to.equal(3000)
})
it('minutes', function() {
expect(apicache.getDuration('4 minutes')).to.equal(1000 * 60 * 4)
})
it('hours', function() {
expect(apicache.getDuration('2 hours')).to.equal(1000 * 60 * 60 * 2)
})
it('days', function() {
expect(apicache.getDuration('3 days')).to.equal(1000 * 60 * 60 * 24 * 3)
})
it('weeks', function() {
expect(apicache.getDuration('5 weeks')).to.equal(1000 * 60 * 60 * 24 * 7 * 5)
})
it('months', function() {
expect(apicache.getDuration('6 months')).to.equal(1000 * 60 * 60 * 24 * 30 * 6)
})
it('years', function() {
expect(apicache.getDuration('3 years')).to.equal(1000 * 60 * 60 * 24 * 365 * 3)
})
})
})
describe('.getPerformance()', function() {
var apicache = require('../src/apicache')
it('is a function', function() {
expect(typeof apicache.getPerformance).to.equal('function')
})
it('returns an array', function() {
expect(Array.isArray(apicache.getPerformance())).to.be.true
})
it('returns a null hit rate if the api has not been called', function() {
var api = require('./api/express')
var app = api.create('10 seconds', { trackPerformance: true })
expect(app.apicache.getPerformance()[0]).to.deep.equal({
callCount: 0,
hitCount: 0,
missCount: 0,
hitRate: null,
hitRateLast100: null,
hitRateLast1000: null,
hitRateLast10000: null,
hitRateLast100000: null,
lastCacheHit: null,
lastCacheMiss: null,
})
})
it('returns a 0 hit rate if the api has been called once', function() {
var api = require('./api/express')
var app = api.create('10 seconds', { trackPerformance: true })
return request(app)
.get('/api/movies')
.then(function(res) {
expect(app.apicache.getPerformance()[0]).to.deep.equal({
callCount: 1,
hitCount: 0,
missCount: 1,
hitRate: 0,
hitRateLast100: 0,
hitRateLast1000: 0,
hitRateLast10000: 0,
hitRateLast100000: 0,
lastCacheHit: null,
lastCacheMiss: app.apicache.getKey({ method: 'get', url: '/api/movies' }),
})
})
})
it('returns a 0.5 hit rate if the api has been called twice', function() {
var api = require('./api/express')
var app = api.create('10 seconds', { trackPerformance: true })
var requests = []
for (var i = 0; i < 2; i++) {
requests.push(request(app).get('/api/movies'))
}
return Promise.all(requests).then(function(res) {
expect(app.apicache.getPerformance()[0]).to.deep.equal({
callCount: 2,
hitCount: 1,
missCount: 1,
hitRate: 0.5,
hitRateLast100: 0.5,
hitRateLast1000: 0.5,
hitRateLast10000: 0.5,
hitRateLast100000: 0.5,
lastCacheHit: app.apicache.getKey({ method: 'get', url: '/api/movies' }),
lastCacheMiss: app.apicache.getKey({ method: 'get', url: '/api/movies' }),
})
})
})
})
describe('.getIndex([groupName]) {GETTER}', function() {
var apicache = require('../src/apicache')
it('is a function', function() {
expect(typeof apicache.getIndex).to.equal('function')
})
it('returns an object', function() {
expect(typeof apicache.getIndex()).to.equal('object')
})
it('can clear indexed cache groups', function() {
var api = require('./api/express')
var app = api.create('10 seconds')
return request(app)
.get('/api/testcachegroup')
.then(function(res) {
expect(app.apicache.getIndex('cachegroup').length).to.equal(1)
})
})
})
describe('.resetIndex() {SETTER}', function() {
var apicache = require('../src/apicache')
it('is a function', function() {
expect(typeof apicache.resetIndex).to.equal('function')
})
})
describe('.middleware {MIDDLEWARE}', function() {
it('is a function', function() {
var apicache = require('../src/apicache')
expect(typeof apicache.middleware).to.equal('function')
expect(apicache.middleware.length).to.equal(3)
})
it('returns the middleware function', function() {
var middleware = require('../src/apicache').middleware('10 seconds')
expect(typeof middleware).to.equal('function')
expect(middleware.length).to.equal(3)
})
it('can be called by a shortcut', function() {
var apicache = require('../src/apicache')
var middleware = apicache('10 seconds')
expect(typeof middleware).to.equal('function')
expect(middleware.length).to.equal(3)
})
describe('signature', function() {
var apicache = require('../src/apicache').newInstance()
;[
{
name: 'regular function',
fn: apicache.middleware.bind(apicache),
},
{
name: 'shortcut function',
fn: apicache,
},
].forEach(function(fnConfig) {
describe(`of ${fnConfig.name}`, function() {
var middlewareFn = fnConfig.fn
it('Apicache#middleware(localOptions)', function() {
var middleware = middlewareFn({ defaultDuration: 1 })
expect(middleware.options().defaultDuration).to.equal(1)
})
it('Apicache#middleware(middlewareToggle, localOptions)', function() {
var middlewareToggleCallCount = 0
var middleware = apicache(
function() {
middlewareToggleCallCount++
return false
},
{ defaultDuration: 1000 }
)
var app = express()
.use(middleware)
.get('/api/signature', function(_req, res) {
app.requestsProcessed++
if (isKoa(_req)) return (_req.body = null)
res.end()
})
app.requestsProcessed = 0
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
expect(middlewareToggleCallCount).to.equal(2)
expect(app.requestsProcessed).to.equal(2)
expect(middleware.options().defaultDuration).to.equal(1000)
})
})
})
it('Apicache#middleware(duration, localOptions)', function() {
var middleware = apicache(40, { defaultDuration: 60000 })
var app = express()
.use(middleware)
.get('/api/signature', function(_req, res) {
app.requestsProcessed++
if (isKoa(_req)) return (_req.body = null)
res.end()
})
app.requestsProcessed = 0
var key = apicache.getKey({ method: 'get', url: '/api/signature' })
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
expect(app.requestsProcessed).to.equal(1)
expect(middleware.options().defaultDuration).to.equal(60000)
return apicache.has(key)
})
.then(function(hasValue) {
expect(hasValue).to.equal(true)
return new Promise(function(resolve) {
setTimeout(function() {
resolve(apicache.has(key))
}, 40)
})
})
.then(function(hasValue) {
expect(hasValue).to.equal(false)
})
})
})
it('Apicache#middleware(duration, middlewareToggle, localOptions)', function() {
var middlewareToggleCallCount = 0
var middleware = apicache(
40,
function() {
middlewareToggleCallCount++
return true
},
{ defaultDuration: 60000 }
)
var app = express()
.use(middleware)
.get('/api/signature', function(_req, res) {
app.requestsProcessed++
if (isKoa(_req)) return (_req.body = null)
res.end()
})
app.requestsProcessed = 0
var key = apicache.getKey({ method: 'get', url: '/api/signature' })
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
return request(app)
.get('/api/signature')
.expect(200)
.then(function() {
expect(middlewareToggleCallCount).to.equal(2)
expect(app.requestsProcessed).to.equal(1)
expect(middleware.options().defaultDuration).to.equal(60000)
return apicache.has(key)
})
.then(function(hasValue) {
expect(hasValue).to.equal(true)
return new Promise(function(resolve) {
setTimeout(function() {
resolve(apicache.has(key))
}, 40)
})
})
.then(function(hasValue) {
expect(hasValue).to.equal(false)
})
})
})
})
})
})
describe('get key name from request properties', function() {
describe('when parts are complete', function() {
beforeEach(function() {
this.cache = require('../src/apicache').newInstance()
this.app = express()
this.app.requestsProcessed = 0
this.app.get(
'/api/getkey',
function(req, _res, next) {
if (isKoa(req)) next = _res
req.query = { a: 1, b: [2] }
req.body = { a: 3, c: 'hi' }
next()
},
this.cache('2 seconds', null, {
append: function(req) {
return req.query.a * 10
},
}),
function(req, res) {
this.app.requestsProcessed++
if (isKoa(req)) return (req.body = movies)
res.json(movies)
}.bind(this)
)
})
afterEach(function() {
this.app.requestsProcessed = 0
})
it('cache with name from parts (with sorted params)', function() {
var that = this
var keyParts = {
method: 'GET',
url: '/api/getkey',
params: { a: 3, b: [2], c: 'hi' },
appendice: 10,
}
var key = this.cache.getKey(keyParts)
expect(key).to.equal('get/api/getkey{"a":3,"b":[2],"c":"hi"}10')
return request(this.app)
.get('/api/getkey')
.expect(200, movies)
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return that.cache.get(key)
})
.then(function(value) {
expect(value.status).to.equal(200)
expect(value.headers['cache-control']).to.equal('private, max-age=2, must-revalidate')
expect(value.data).to.eql(movies)
return request(that.app)
.get('/api/getkey')
.expect(200, movies)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
})
})
})
describe('when parts are incomplete', function() {
beforeEach(function() {
this.cache = require('../src/apicache').newInstance()
this.app = express()
this.app.requestsProcessed = 0
this.app.get(
'/api/getkey',
this.cache('2 seconds'),
function(req, res) {
this.app.requestsProcessed++
if (isKoa(req)) return (req.body = movies)
res.json(movies)
}.bind(this)
)
})
afterEach(function() {
this.app.requestsProcessed = 0
})
it('return key name (keeping {} as separator)', function() {
var that = this
var keyParts = {
method: 'GET',
url: '/api/getkey',
}
var key = this.cache.getKey(keyParts)
expect(this.cache.getKey(keyParts)).to.equal('get/api/getkey{}')
return request(this.app)
.get('/api/getkey')
.expect(200, movies)
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return that.cache.get(key)
})
.then(function(value) {
expect(value.status).to.equal(200)
expect(value.headers['cache-control']).to.equal('max-age=2, must-revalidate')
expect(value.data).to.eql(movies)
return request(that.app)
.get('/api/getkey')
.expect(200, movies)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
})
})
})
describe('with options.interceptKeyParts', function() {
beforeEach(function() {
this.cache = require('../src/apicache').newInstance()
this.app = express()
this.app.requestsProcessed = 0
this.app.post(
'/api/getkeyinterception',
this.cache(),
function(req, res) {
this.app.requestsProcessed++
if (isKoa(req)) return (req.body = movies)
res.json(movies)
}.bind(this)
)
})
afterEach(function() {
this.app.requestsProcessed = 0
})
describe('returning new key parts', function() {
beforeEach(function() {
this.app.get(
'/api/getkey',
function(req, _res, next) {
if (isKoa(req)) next = _res
req.query = { a: 1, b: [2] }
req.body = { a: 3, c: 'hi' }
next()
},
this.cache('2 seconds', null, {
append: function(req) {
return req.query.a * 10
},
interceptKeyParts: function(req, res, parts) {
return { method: 'POST', url: parts.url + 'interception' }
},
}),
function(req, res) {
this.app.requestsProcessed++
if (isKoa(req)) return (req.body = movies)
res.json(movies)
}.bind(this)
)
})
it('can use intercepted key parts', function() {
var that = this
var keyParts = {
method: 'POST',
url: '/api/getkeyinterception',
}
var key = this.cache.getKey(keyParts)
expect(key).to.equal('post/api/getkeyinterception{}')
return request(this.app)
.get('/api/getkey')
.expect('Cache-Control', 'private, max-age=2, must-revalidate')
.expect(200, movies)
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return that.cache.get(key)
})
.then(function(value) {
expect(value.status).to.equal(200)
expect(value.headers['cache-control']).to.equal('private, max-age=2, must-revalidate')
expect(value.data).to.eql(movies)
return request(that.app)
.post('/api/getkeyinterception')
.expect('Cache-Control', 'no-store')
.expect(200, movies)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.get('/api/getkey')
.expect('Cache-Control', 'private, max-age=2, must-revalidate')
.expect(200, movies)
})
.then(function(value) {
expect(that.app.requestsProcessed).to.equal(1)
})
})
})
describe('mutating key parts', function() {
beforeEach(function() {
this.app.get(
'/api/getkey',
function(req, _res, next) {
if (isKoa(req)) next = _res
req.query = { a: 1, b: [2] }
req.body = { a: 3, c: 'hi' }
next()
},
this.cache('2 seconds', null, {
append: function(req) {
return req.query.a * 10
},
interceptKeyParts: function(req, res, parts) {
parts.method = 'post'
parts.url = parts.url + 'interception'
parts.params = null
delete parts.appendice
},
}),
function(req, res) {
this.app.requestsProcessed++
if (isKoa(req)) return (req.body = movies)
res.json(movies)
}.bind(this)
)
})
it('can use intercepted key parts', function() {
var that = this
var keyParts = {
method: 'post',
url: '/api/getkeyinterception',
}
var key = this.cache.getKey(keyParts)
expect(key).to.equal('post/api/getkeyinterception{}')
return request(this.app)
.get('/api/getkey')
.expect('Cache-Control', 'private, max-age=2, must-revalidate')
.expect(200, movies)
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return that.cache.get(key)
})
.then(function(value) {
expect(value.status).to.equal(200)
expect(value.headers['cache-control']).to.equal('private, max-age=2, must-revalidate')
expect(value.data).to.eql(movies)
return request(that.app)
.post('/api/getkeyinterception')
.expect('Cache-Control', 'no-store')
.expect(200, movies)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.get('/api/getkey')
.expect('Cache-Control', 'private, max-age=2, must-revalidate')
.expect(200, movies)
})
.then(function(value) {
expect(that.app.requestsProcessed).to.equal(1)
})
})
})
})
})
describe('options', function() {
var apicache = require('../src/apicache').newInstance()
it('uses global options if local ones not provided', function() {
apicache.options({
append: ['test'],
})
var middleware1 = apicache.middleware('10 seconds')
var middleware2 = apicache.middleware('20 seconds')
expect(middleware1.options()).to.eql({
debug: false,
defaultDuration: 3600000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['test'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: [] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
expect(middleware2.options()).to.eql({
debug: false,
defaultDuration: 3600000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['test'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: [] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
})
it('uses local options if they provided', function() {
apicache.options({
append: ['test'],
})
function afterHit() {}
var middleware1 = apicache.middleware('10 seconds', null, {
debug: true,
isBypassable: true,
interceptKeyParts: null,
defaultDuration: 7200000,
append: ['bar'],
statusCodes: { include: [], exclude: ['400'] },
events: { expire: undefined },
headers: {
'cache-control': 'no-cache',
},
afterHit: afterHit,
})
var middleware2 = apicache.middleware('20 seconds', null, {
debug: false,
defaultDuration: 1800000,
append: ['foo'],
statusCodes: { include: [], exclude: ['200'] },
events: { expire: undefined },
})
expect(middleware1.options()).to.eql({
debug: true,
defaultDuration: 7200000,
enabled: true,
isBypassable: true,
interceptKeyParts: null,
append: ['bar'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: ['400'] },
events: { expire: undefined },
headers: {
'cache-control': 'no-cache',
},
shouldSyncExpiration: false,
afterHit: afterHit,
trackPerformance: false,
optimizeDuration: false,
})
expect(middleware2.options()).to.eql({
debug: false,
defaultDuration: 1800000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['foo'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: ['200'] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
})
it('updates options if global ones changed', function() {
apicache.options({
debug: true,
append: ['test'],
})
var middleware1 = apicache.middleware('10 seconds', null, {
defaultDuration: 7200000,
statusCodes: { include: [], exclude: ['400'] },
})
var middleware2 = apicache.middleware('20 seconds', null, {
defaultDuration: 1800000,
statusCodes: { include: [], exclude: ['200'] },
})
apicache.options({
debug: false,
append: ['foo'],
})
expect(middleware1.options()).to.eql({
debug: false,
defaultDuration: 7200000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['foo'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: ['400'] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
expect(middleware2.options()).to.eql({
debug: false,
defaultDuration: 1800000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['foo'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: ['200'] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
})
it('updates options if local ones changed', function() {
apicache.options({
debug: true,
append: ['test'],
})
var middleware1 = apicache.middleware('10 seconds', null, {
defaultDuration: 7200000,
statusCodes: { include: [], exclude: ['400'] },
})
var middleware2 = apicache.middleware('20 seconds', null, {
defaultDuration: 900000,
statusCodes: { include: [], exclude: ['404'] },
})
middleware1.options({
debug: false,
defaultDuration: 1800000,
append: ['foo'],
headers: {
'cache-control': 'no-cache',
},
})
middleware2.options({
defaultDuration: 450000,
enabled: false,
append: ['foo'],
})
expect(middleware1.options()).to.eql({
debug: false,
defaultDuration: 1800000,
enabled: true,
isBypassable: false,
interceptKeyParts: null,
append: ['foo'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: [] },
events: { expire: undefined },
headers: {
'cache-control': 'no-cache',
},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
expect(middleware2.options()).to.eql({
debug: true,
defaultDuration: 450000,
enabled: false,
isBypassable: false,
interceptKeyParts: null,
append: ['foo'],
jsonp: false,
redisClient: false,
redisPrefix: '',
headerBlacklist: [],
statusCodes: { include: [], exclude: [] },
events: { expire: undefined },
headers: {},
shouldSyncExpiration: false,
afterHit: null,
trackPerformance: false,
optimizeDuration: false,
})
})
})
it('can change global defaultDuration at runtime', function() {
apicache = apicache.newInstance()
apicache.options({
defaultDuration: '30 seconds',
})
var middleware = apicache.middleware()
var app = express()
app.get('/api/localduration', middleware, function(_req, res) {
if (isKoa(_req)) return (_req.body = null)
res.end()
})
apicache.options({
defaultDuration: '20 seconds',
})
return request(app)
.get('/api/localduration')
.expect(200)
.expect('Cache-Control', 'max-age=20, must-revalidate')
})
it('can change local defaultDuration at runtime', function() {
var middleware = apicache.newInstance().middleware(null, null, { defaultDuration: '2 seconds' })
var app = express()
app.get('/api/localduration', middleware, function(_req, res) {
if (isKoa(_req)) return (_req.body = null)
res.end()
})
middleware.options({
defaultDuration: '10 seconds',
})
return request(app)
.get('/api/localduration')
.expect(200)
.expect('Cache-Control', 'max-age=10, must-revalidate')
})
it('can change local defaultDuration at runtime even if global one changed later', function() {
apicache = apicache.newInstance()
var middleware = apicache.middleware(null, null, { defaultDuration: '2 seconds' })
var app = express()
app.get('/api/localduration', middleware, function(_req, res) {
if (isKoa(_req)) return (_req.body = null)
res.end()
})
middleware.options({
defaultDuration: '10 seconds',
})
apicache.options({
defaultDuration: '40 seconds',
})
return request(app)
.get('/api/localduration')
.expect(200)
.expect('Cache-Control', 'max-age=10, must-revalidate')
})
describe('when wrong encoding header is set too early by other middleware', function() {
it('will fix headers', function() {
var app = express()
app.requestsProcessed = 0
function setWrongEncoding(req, res, next) {
if (isKoa(req)) {
next = res
res = req.res
}
// should be set inside custom res.writeHead but sometimes isn't
res.setHeader('Content-Encoding', 'gzip')
var _writeHead = res.writeHead
res.writeHead = function() {
// expect headers not to be sent
this.removeHeader('Content-Length')
// instead of really encoding body to gzip
this.setHeader('Content-Encoding', 'identity')
return _writeHead.apply(this, arguments)
}
next()
}
function respond(req, res) {
app.requestsProcessed++
if (isKoa(req)) {
req.respond = false
res = req.res
}
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.write('wrong encoding')
res.end()
}
var apicacheMiddleware = apicache.newInstance().middleware()
app.get('/api/wrongencoding', setWrongEncoding, apicacheMiddleware, respond)
var req = request(app)
return request(app)
.get('/api/wrongencoding')
.expect(200, 'wrong encoding')
.expect('Content-Type', 'text/plain')
.then(function(res) {
expect(res.headers['content-encoding'] || 'identity').to.equal('identity')
expect(app.requestsProcessed).to.equal(1)
return req
.get('/api/wrongencoding')
.expect(200, 'wrong encoding')
.expect('Content-Type', 'text/plain')
.then(function() {
expect(res.headers['content-encoding'] || 'identity').to.equal('identity')
expect(app.requestsProcessed).to.equal(1)
})
})
})
describe("when patched methods don't set implict headers immediately", function() {
it('will fix headers', function() {
var app = express()
app.requestsProcessed = 0
function changeWriteBehavior(req, res, next) {
if (isKoa(req)) {
next = res
res = req.res
}
// should be set inside custom res.writeHead but sometimes isn't
res.setHeader('Content-Encoding', 'gzip')
var _writeHead = res.writeHead
res.writeHead = function() {
// expect headers not to be sent
this.removeHeader('Content-Length')
// instead of really encoding body to gzip
this.setHeader('Content-Encoding', 'identity')
return _writeHead.apply(this, arguments)
}
var _write = res.write
res.write = function() {
var args = arguments
setImmediate(function() {
_write.apply(res, args)
})
return res
}
var _end = res.end
res.end = function() {
var args = arguments
setImmediate(function() {
_end.apply(res, args)
})
return res
}
next()
}
function respond(_req, res) {
app.requestsProcessed++
if (isKoa(_req)) {
_req.respond = false
res = _req.res
}
res.statusCode = 201
res.setHeader('Content-Type', 'text/plain')
res.write('take this')
res.end()
}
var cache = apicache.newInstance()
app.get('/api/wrongencoding', changeWriteBehavior, cache(), respond)
return request(app)
.get('/api/wrongencoding')
.expect(201, 'take this')
.expect('Content-Type', 'text/plain')
.then(function(res) {
expect(res.headers['content-encoding'] || 'identity').to.equal('identity')
expect(app.requestsProcessed).to.equal(1)
return request(app)
.get('/api/wrongencoding')
.expect(201, 'take this')
.expect('Content-Type', 'text/plain')
.then(function(res) {
expect(res.headers['content-encoding'] || 'identity').to.equal('identity')
expect(app.requestsProcessed).to.equal(1)
})
})
})
})
})
apis.forEach(function(api) {
describe(api.name + ' tests', function() {
var mockAPI = api.server
it('does not interfere with initial request', function() {
var app = mockAPI.create('10 seconds')
return request(app)
.get('/api/movies')
.expect(200)
.then(assertNumRequestsProcessed(app, 1))
})
it('properly returns a request while caching (first call)', function() {
var app = mockAPI.create('10 seconds')
return request(app)
.get('/api/movies')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
})
it('returns max-age header on first request', function() {
var app = mockAPI.create('10 seconds')
return request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', /max-age/)
})
it('returns properly decremented max-age header on cached response', function(done) {
var app = mockAPI.create('10 seconds')
request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=10, must-revalidate')
.then(function(res) {
setTimeout(function() {
request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=9, must-revalidate')
.then(function() {
expect(app.requestsProcessed).to.equal(1)
done()
})
.catch(function(err) {
done(err)
})
}, 1000)
})
.catch(done)
})
;['post', 'put', 'patch', api.name.indexOf('restify') !== -1 ? 'del' : 'delete'].forEach(
function(method) {
describe(`when ${method.toUpperCase()}`, function() {
before(function() {
var that = this
this.app = mockAPI.create('10 seconds')
this.app[method]('/api/nongethead', function(req, res) {
that.app.requestsProcessed++
if (isKoa(req)) {
req.respond = false
res = req.res
}
res.statusCode = 200
res.write('non get nor head')
res.end()
})
})
it('returns no-store header', function() {
var that = this
return request(this.app)
[method]('/api/nongethead')
.expect(200, 'non get nor head')
.expect('Cache-Control', 'no-store')
.then(function(res) {
return request(that.app)
[method]('/api/nongethead')
.expect(200, 'non get nor head')
.expect('Cache-Control', 'no-store')
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
var key = that.app.apicache.getKey({
method: method === 'del' ? 'delete' : method,
url: '/api/nongethead',
})
return that.app.apicache.get(key)
})
})
.then(function(cached) {
expect(cached.headers['cache-control']).to.equal('no-store')
})
})
})
}
)
describe('when head request', function() {
beforeEach(function() {
var that = this
this.app = mockAPI.create('10 seconds')
this.app.get.restoreDefaultBehavior && this.app.get.restoreDefaultBehavior()
this.app.get('/api/headget', function(req, res) {
that.app.requestsProcessed++
if (isKoa(req)) {
req.body = 'headget response'
} else {
res.write('headget response')
res.end()
}
})
this.app.head('/api/headget', function(req, res) {
that.app.requestsProcessed++
if (isKoa(req)) {
req.status = 200
} else res.end()
})
})
describe('when get is cached', function() {
it('fallback to get', function() {
var that = this
return request(this.app)
.get('/api/headget')
.expect(200, 'headget response')
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.head('/api/headget')
.expect(200, undefined)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.get('/api/headget')
.expect(200, 'headget response')
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
})
})
})
describe('when get is not cached', function() {
it("doesn't fallback to get", function() {
var that = this
return request(this.app)
.head('/api/headget')
.expect(200, undefined)
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.head('/api/headget')
.expect(200, undefined)
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(1)
return request(that.app)
.get('/api/headget')
.expect(200, 'headget response')
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(2)
return request(that.app)
.get('/api/headget')
.expect(200, 'headget response')
})
.then(function() {
expect(that.app.requestsProcessed).to.equal(2)
})
})
})
})
it('returns decremented max-age header when overwritten one is higher than cache duration', function(done) {
var app = mockAPI.create('10 seconds', { headers: { 'cache-control': 'max-age=15' } })
request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=15')
.then(function(res) {
setTimeout(function() {
request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=9')
.then(function() {
expect(app.requestsProcessed).to.equal(1)
done()
})
.catch(function(err) {
done(err)
})
}, 1000)
})
})
it('returns overwritten max-age header when lower than cache duration', function() {
var app = mockAPI.create('10 seconds', { headers: { 'cache-control': 'max-age=5' } })
return request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=5')
.then(function() {
return request(app)
.get('/api/movies')
.expect(200, movies)
.expect('Cache-Control', 'max-age=5')
.then(function() {
expect(app.requestsProcessed).to.equal(1)
})
})
})
it('return a low max-age when apicacheGroup is set', function() {
var app = mockAPI.create('10 seconds')
return request(app)
.get('/api/testcachegroup')
.expect('Cache-Control', 'max-age=3, must-revalidate')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/testcachegroup')
.expect('Cache-Control', 'max-age=3, must-revalidate')
.then(assertNumRequestsProcessed(app, 1))
})
})
it('return 30s max-age when syncing is off', function() {
var app = mockAPI.create('40 seconds')
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'max-age=30, must-revalidate')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'max-age=30, must-revalidate')
.then(assertNumRequestsProcessed(app, 1))
})
})
it('return regular max-age when syncing is on', function() {
var app = mockAPI.create('40 seconds', { shouldSyncExpiration: true })
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'max-age=40, must-revalidate')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'max-age=40, must-revalidate')
.then(assertNumRequestsProcessed(app, 1))
})
})
it('return private cache-control when options.append is set', function() {
var app = mockAPI.create('40 seconds', { shouldSyncExpiration: true, append: () => null })
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'private, max-age=40, must-revalidate')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'private, max-age=40, must-revalidate')
.then(assertNumRequestsProcessed(app, 1))
})
})
// naive optimization that just makes sure duration isn't higher than 5 min when append is set
// a better one would probably need stats recording
// and is not that useful when maxMemory option is set
it('lower cache duration when optimizeDuration is on', function() {
var app = mockAPI.create('10 minutes', {
shouldSyncExpiration: true,
optimizeDuration: true,
append: () => null,
})
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'private, max-age=300, must-revalidate')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.expect('Cache-Control', 'private, max-age=300, must-revalidate')
.then(assertNumRequestsProcessed(app, 1))
})
})
it('skips cache when using header "cache-control: no-store"', function() {
var app = mockAPI.create('10 seconds', { isBypassable: true })
return request(app)
.get('/api/movies')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.set('cache-control', 'no-store')
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200, movies)
.then(function(res) {
expect(res.headers['apicache-store']).to.be.undefined
expect(res.headers['apicache-version']).to.be.undefined
expect(app.requestsProcessed).to.equal(2)
})
})
})
it('skips cache when using header "x-apicache-bypass"', function() {
var app = mockAPI.create('10 seconds', { isBypassable: true })
return request(app)
.get('/api/movies')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.set('x-apicache-bypass', true)
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200, movies)
.then(function(res) {
expect(res.headers['apicache-store']).to.be.undefined
expect(res.headers['apicache-version']).to.be.undefined
expect(app.requestsProcessed).to.equal(2)
})
})
})
it('skips cache when using header "x-apicache-force-fetch (legacy)"', function() {
var app = mockAPI.create('10 seconds', { isBypassable: true })
return request(app)
.get('/api/movies')
.expect(200, movies)
.then(assertNumRequestsProcessed(app, 1))
.then(function() {
return request(app)
.get('/api/movies')
.set('x-apicache-force-fetch', true)
.set('Accept', 'application/json')
.expect('Content-Type', /json/)
.expect(200, movies)
.then(function(res) {
expect(res.headers['apicache-store']).to.be.undefined
expect(res.headers['apicache-version']).to.be.undefined
expect(app.requestsProcessed).to.equal(2)
})
})
})
it('prevent cache skipping when using header "cache-control: no-store" with isBypassable "false"', function() {
var app = moc