UNPKG

apicache-plus

Version:

Effortless api response caching for Express/Node using plain-english durations

1,510 lines (1,389 loc) 148 kB
/* 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