diff --git a/package-lock.json b/package-lock.json index 33e175d1..e1a5f266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", + "lru-cache": "^10.0.3", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "openid-client": "^5.6.1", @@ -603,6 +604,17 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -641,6 +653,18 @@ "semver": "^7.3.5" } }, + "node_modules/@npmcli/fs/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@npmcli/fs/node_modules/semver": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", @@ -1126,6 +1150,18 @@ "node": ">= 10" } }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -2619,6 +2655,18 @@ "node": ">=8" } }, + "node_modules/istanbul-lib-report/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/istanbul-lib-report/node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -2783,6 +2831,17 @@ "npm": ">=6" } }, + "node_modules/jsonwebtoken/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jsonwebtoken/node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2917,14 +2976,11 @@ } }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz", + "integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==", "engines": { - "node": ">=10" + "node": "14 || >=16.14" } }, "node_modules/make-dir": { @@ -2976,6 +3032,18 @@ "node": ">= 10" } }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3552,6 +3620,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/node-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -3841,6 +3921,17 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4386,6 +4477,17 @@ } } }, + "node_modules/sequelize/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sequelize/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5777,6 +5879,14 @@ "tar": "^6.1.11" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -5805,6 +5915,15 @@ "semver": "^7.3.5" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", @@ -6192,6 +6311,17 @@ "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + } } }, "caching-transform": { @@ -7291,6 +7421,15 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, "make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7408,6 +7547,14 @@ "semver": "^7.5.4" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7529,12 +7676,9 @@ } }, "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "requires": { - "yallist": "^4.0.0" - } + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz", + "integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==" }, "make-dir": { "version": "3.1.0", @@ -7573,6 +7717,17 @@ "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + } } }, "media-typer": { @@ -8000,6 +8155,15 @@ "wide-align": "^1.1.5" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -8220,6 +8384,16 @@ "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + } } }, "p-limit": { @@ -8588,6 +8762,14 @@ "ms": "2.1.2" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index 2066fa78..477f62af 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", + "lru-cache": "^10.0.3", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "openid-client": "^5.6.1", diff --git a/server/Server.js b/server/Server.js index e7be5492..4883fb71 100644 --- a/server/Server.js +++ b/server/Server.js @@ -32,13 +32,13 @@ const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') +const ApiCacheManager = require('./managers/ApiCacheManager') const LibraryScanner = require('./scanner/LibraryScanner') //Import the main Passport and Express-Session library const passport = require('passport') const expressSession = require('express-session') - class Server { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { this.Port = PORT @@ -73,6 +73,7 @@ class Server { this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) + this.apiCacheManager = new ApiCacheManager() // Routers this.apiRouter = new ApiRouter(this) @@ -117,6 +118,7 @@ class Server { const libraries = await Database.libraryModel.getAllOldLibraries() await this.cronManager.init(libraries) + this.apiCacheManager.init() if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 31012107..da17f5df 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -192,9 +192,9 @@ class SocketAuthority { this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) - // Update user lastSeen + // Update user lastSeen without firing sequelize bulk update hooks user.lastSeen = Date.now() - await Database.updateUser(user) + await Database.userModel.updateFromOld(user, false) const initialPayload = { userId: client.user.id, diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js new file mode 100644 index 00000000..c6579ab3 --- /dev/null +++ b/server/managers/ApiCacheManager.js @@ -0,0 +1,54 @@ +const { LRUCache } = require('lru-cache') +const Logger = require('../Logger') +const Database = require('../Database') + +class ApiCacheManager { + + defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) } + defaultTtlOptions = { ttl: 30 * 60 * 1000 } + + constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) { + this.cache = cache + this.ttlOptions = ttlOptions + } + + init(database = Database) { + let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy'] + hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) + } + + clear(model, hook) { + Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`) + this.cache.clear() + } + + get middleware() { + return (req, res, next) => { + const key = { user: req.user.username, url: req.url } + const stringifiedKey = JSON.stringify(key) + Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`) + const cached = this.cache.get(stringifiedKey) + if (cached) { + Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`) + res.set(cached.headers) + res.status(cached.statusCode) + res.send(cached.body) + return + } + res.originalSend = res.send + res.send = (body) => { + Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`) + const cached = { body, headers: res.getHeaders(), statusCode: res.statusCode } + if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) { + Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`) + this.cache.set(stringifiedKey, cached, this.ttlOptions) + } else { + this.cache.set(stringifiedKey, cached) + } + res.originalSend(body) + } + next() + } + } +} +module.exports = ApiCacheManager \ No newline at end of file diff --git a/server/models/User.js b/server/models/User.js index 4c348f42..220c0c40 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -99,11 +99,13 @@ class User extends Model { * Update User from old user model * * @param {oldUser} oldUser + * @param {boolean} [hooks=true] Run before / after bulk update hooks? * @returns {Promise} */ - static updateFromOld(oldUser) { + static updateFromOld(oldUser, hooks = true) { const user = this.getFromOld(oldUser) return this.update(user, { + hooks: !!hooks, where: { id: user.id } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8c97d59b..d7714568 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -48,6 +48,7 @@ class ApiRouter { this.cronManager = Server.cronManager this.notificationManager = Server.notificationManager this.emailManager = Server.emailManager + this.apiCacheManager = Server.apiCacheManager this.router = express() this.router.disable('x-powered-by') @@ -58,6 +59,7 @@ class ApiRouter { // // Library Routes // + this.router.get(/^\/libraries/, this.apiCacheManager.middleware) this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js new file mode 100644 index 00000000..dc1ee1ed --- /dev/null +++ b/test/server/managers/ApiCacheManager.test.js @@ -0,0 +1,97 @@ +// Import dependencies and modules for testing +const { expect } = require('chai') +const sinon = require('sinon') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') + +describe('ApiCacheManager', () => { + let cache + let req + let res + let next + let manager + + beforeEach(() => { + cache = { get: sinon.stub(), set: sinon.spy() } + req = { user: { username: 'testUser' }, url: '/test-url' } + res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() } + next = sinon.spy() + }) + + describe('middleware', () => { + it('should send cached data if available', () => { + // Arrange + const cachedData = { body: 'cached data', headers: { 'content-type': 'application/json' }, statusCode: 200 } + cache.get.returns(cachedData) + const key = JSON.stringify({ user: req.user.username, url: req.url }) + manager = new ApiCacheManager(cache) + + // Act + manager.middleware(req, res, next) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(res.set.calledOnce).to.be.true + expect(res.set.calledWith(cachedData.headers)).to.be.true + expect(res.status.calledOnce).to.be.true + expect(res.status.calledWith(cachedData.statusCode)).to.be.true + expect(res.send.calledOnce).to.be.true + expect(res.send.calledWith(cachedData.body)).to.be.true + expect(res.originalSend).to.be.undefined + expect(next.called).to.be.false + expect(cache.set.called).to.be.false + }) + + it('should cache and send response if data is not cached', () => { + // Arrange + cache.get.returns(null) + const headers = { 'content-type': 'application/json' } + res.getHeaders.returns(headers) + const body = 'response data' + const statusCode = 200 + const responseData = { body, headers, statusCode } + const key = JSON.stringify({ user: req.user.username, url: req.url }) + manager = new ApiCacheManager(cache) + + // Act + manager.middleware(req, res, next) + res.send(body) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(next.calledOnce).to.be.true + expect(cache.set.calledOnce).to.be.true + expect(cache.set.calledWith(key, responseData)).to.be.true + expect(res.originalSend.calledOnce).to.be.true + expect(res.originalSend.calledWith(body)).to.be.true + }) + + it('should cache personalized response with 30 minutes TTL', () => { + // Arrange + cache.get.returns(null) + const headers = { 'content-type': 'application/json' } + res.getHeaders.returns(headers) + const body = 'personalized data' + const statusCode = 200 + const responseData = { body, headers, statusCode } + req.url = '/libraries/id/personalized' + const key = JSON.stringify({ user: req.user.username, url: req.url }) + const ttlOptions = { ttl: 30 * 60 * 1000 } + manager = new ApiCacheManager(cache, ttlOptions) + + // Act + manager.middleware(req, res, next) + res.send(body) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(next.calledOnce).to.be.true + expect(cache.set.calledOnce).to.be.true + expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true + expect(res.originalSend.calledOnce).to.be.true + expect(res.originalSend.calledWith(body)).to.be.true + }) + }) +}) \ No newline at end of file