├── .github └── workflows │ └── ci-plugin.yml ├── .gitignore ├── API.md ├── LICENSE.md ├── README.md ├── docker-compose.yml ├── lib ├── index.d.ts └── index.js ├── package.json └── test ├── cluster.js ├── esm.js ├── index.js ├── mock.js └── types.ts /.github/workflows/ci-plugin.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: [ubuntu, macos] 16 | node: ["*", "16", "14"] 17 | runs-on: ${{ matrix.os }}-latest 18 | name: ${{ matrix.os }} node@${{ matrix.node }} 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node }} 24 | check-latest: ${{ matrix.node == '*' }} 25 | - name: Install 26 | run: npm install 27 | - name: Start docker 28 | if: matrix.os == 'macos' 29 | run: brew install docker docker-compose && colima start 30 | - name: Start containers 31 | run: docker-compose up -d 32 | - name: Sleep for 5s 33 | if: matrix.os != 'macos' 34 | run: sleep 5s; 35 | - name: Sleep for 5s 36 | if: matrix.os == 'macos' 37 | run: sleep 5; 38 | - name: test 39 | run: npm test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/package-lock.json 3 | 4 | coverage.* 5 | 6 | **/.DS_Store 7 | **/._* 8 | 9 | **/*.pem 10 | 11 | **/.vs 12 | **/.vscode 13 | **/.idea 14 | -------------------------------------------------------------------------------- /API.md: -------------------------------------------------------------------------------- 1 | 2 | ### `new CatboxRedis.Engine(options)` 3 | 4 | The connection can be specified with one (and only one) of: 5 | 6 | - `client` - a custom Redis client instance where `client` must: 7 | - be manually started and stopped, 8 | - be compatible with the **ioredis** module API, and 9 | - expose the `status` property that must be set to `'ready'` when connected. 10 | 11 | - `url` - a Redis server URL. 12 | 13 | - `socket` - a unix socket string. 14 | 15 | - `cluster` - an array of `{ host, port }` pairs. 16 | 17 | Or: 18 | 19 | - `host` - a Redis server hostname. Defaults to `'127.0.0.1'` if no other connection method specified from the above. 20 | - `port` - a Redis server port or unix domain socket path. Defaults to `6379` if no other connection method specified from the above. 21 | 22 | **catbox** options: 23 | 24 | - `partition` - a string used to prefix all item keys with. Defaults to `''`. 25 | 26 | Other supported Redis options: 27 | 28 | - `password` - the Redis authentication password when required. 29 | - `db` - a Redis database name or number. 30 | - `sentinels` - an array of `{ host, port }` sentinel address pairs. 31 | - `sentinelName` - the name of the sentinel master (when `sentinels` is specified). 32 | - `tls` - an object representing TLS config options for **ioredis**. 33 | 34 | The plugin also accepts other `redis` options not mentioned above. 35 | 36 | 37 | ### Usage 38 | 39 | Sample catbox cache initialization: 40 | 41 | ```js 42 | const Catbox = require('@hapi/catbox'); 43 | const { Engine: CatboxRedis } = require('@hapi/catbox-redis'); 44 | 45 | 46 | const cache = new Catbox.Client(CatboxRedis, { 47 | partition : 'my_cached_data', 48 | host: 'redis-cluster.domain.com', 49 | port: 6379, 50 | db: 0, 51 | tls: {}, 52 | }); 53 | ``` 54 | 55 | When used in a hapi server (hapi version 18 or newer): 56 | 57 | ```js 58 | const Hapi = require('hapi') 59 | const { Engine: CatboxRedis } = require('@hapi/catbox-redis'); 60 | 61 | const server = new Hapi.Server({ 62 | cache : [ 63 | { 64 | name: 'my_cache', 65 | provider: { 66 | constructor: CatboxRedis, 67 | options: { 68 | partition : 'my_cached_data', 69 | host: 'redis-cluster.domain.com', 70 | port: 6379, 71 | db: 0, 72 | tls: {}, 73 | } 74 | } 75 | } 76 | ] 77 | }); 78 | ``` 79 | 80 | 81 | ### Tests 82 | 83 | The test suite expects: 84 | - a redis server to be running on port 6379 85 | - a redis server listenning to port 6378 and requiring a password: 'secret' 86 | - a redis cluster contains nodes running on ports 7000 to 7005 87 | 88 | See [docker-compose.yml](./docker-compose.yml) 89 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2022, Project contributors 2 | Copyright (c) 2012-2020, Sideway Inc 3 | Copyright (c) 2012-2014, Walmart. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | * The names of any contributors may not be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS OFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # @hapi/catbox-redis 4 | 5 | #### Redis adapter for [catbox](https://github.com/hapijs/catbox). 6 | 7 | **catbox-redis** is part of the **hapi** ecosystem and was designed to work seamlessly with the [hapi web framework](https://hapi.dev) and its other components (but works great on its own or with other frameworks). If you are using a different web framework and find this module useful, check out [hapi](https://hapi.dev) – they work even better together. 8 | 9 | ### Visit the [hapi.dev](https://hapi.dev) Developer Portal for tutorials, documentation, and support 10 | 11 | ## Useful resources 12 | 13 | - [Documentation and API](https://hapi.dev/family/catbox-redis/) 14 | - [Version status](https://hapi.dev/resources/status/#catbox-redis) (builds, dependencies, node versions, licenses, eol) 15 | - [Changelog](https://hapi.dev/family/catbox-redis/changelog/) 16 | - [Project policies](https://hapi.dev/policies/) 17 | - [Free and commercial support options](https://hapi.dev/support/) 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | services: 3 | redis_basic: 4 | container_name: redis_basic 5 | image: redis:6-alpine 6 | ports: 7 | - 6379:6379 8 | 9 | redis_with_password: 10 | container_name: redis_with_password 11 | image: redis:6-alpine 12 | command: redis-server --requirepass secret 13 | ports: 14 | - 6378:6379 15 | 16 | redis_cluster: 17 | container_name: redis_cluster 18 | image: grokzen/redis-cluster:6.2.8 19 | environment: 20 | IP: '0.0.0.0' 21 | CLUSTER_ONLY: 'true' 22 | ports: 23 | - 7000:7000 24 | - 7001:7001 25 | - 7002:7002 26 | - 7003:7003 27 | - 7004:7004 28 | - 7005:7005 29 | -------------------------------------------------------------------------------- /lib/index.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for @hapi/catbox-redis 7.0 2 | // Project: https://github.com/hapijs/catbox-redis 3 | // Definitions by: Simon Schick 4 | // Silas Rech 5 | // Danilo Alonso 6 | // TypeScript Version: 5 7 | 8 | import { EnginePrototype, ClientOptions, Client } from '@hapi/catbox'; 9 | import Redis, { Cluster } from 'ioredis'; 10 | 11 | export interface CatboxRedisOptions extends ClientOptions { 12 | 13 | /** 14 | * Raw client. 15 | */ 16 | client?: Redis | Cluster | undefined; 17 | /** 18 | * the Redis server URL (if url is provided, host, port, and socket are ignored) 19 | */ 20 | url?: string | undefined; 21 | /** 22 | * the Redis server hostname. 23 | * Defaults to '127.0.0.1'. 24 | */ 25 | host?: string | undefined; 26 | /** 27 | * the Redis server port or unix domain socket path. 28 | * Defaults to 6379. 29 | */ 30 | port?: number | undefined; 31 | /** 32 | * the unix socket string to connect to (if socket is provided, host and port are ignored) 33 | */ 34 | socket?: string | undefined; 35 | /** 36 | * the Redis authentication password when required. 37 | */ 38 | password?: string | undefined; 39 | /** 40 | * the Redis database. 41 | */ 42 | database?: string | undefined; 43 | /** 44 | * an array of redis sentinel addresses to connect to. 45 | */ 46 | sentinels?: Array<{ 47 | host: string; 48 | }> | undefined; 49 | /** 50 | * the name of the sentinel master. 51 | * (Only needed when sentinels is specified) 52 | */ 53 | sentinelName?: string | undefined; 54 | } 55 | 56 | export class Engine extends Client { 57 | 58 | constructor (opts: CatboxRedisOptions); 59 | } 60 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Bourne = require('@hapi/bourne'); 4 | const Hoek = require('@hapi/hoek'); 5 | const IoRedis = require('ioredis'); 6 | const Joi = require('joi'); 7 | 8 | 9 | const internals = { 10 | schema: { 11 | partition: Joi.string().default(''), 12 | host: Joi.object({ 13 | host: Joi.string().default('127.0.0.1'), 14 | port: Joi.number().integer().positive().default(6379) 15 | }) 16 | } 17 | }; 18 | 19 | 20 | internals.schema.cluster = Joi.array() 21 | .items(internals.schema.host) 22 | .min(1); 23 | 24 | 25 | internals.schema.common = Joi.object({ 26 | 27 | partition: internals.schema.partition, 28 | 29 | // Redis options 30 | 31 | db: [Joi.string(), Joi.number()], 32 | 33 | password: Joi.string().allow(''), 34 | tls: Joi.object(), 35 | sentinels: internals.schema.cluster, 36 | name: Joi.string() 37 | }) 38 | .rename('database', 'db') 39 | .rename('sentinelName', 'name') 40 | .without('db', 'database') 41 | .with('name', 'sentinels') 42 | .unknown(); 43 | 44 | 45 | internals.schema.options = Joi.alternatives([ 46 | Joi.object({ 47 | client: Joi.object().required(), 48 | partition: internals.schema.partition 49 | }) 50 | .unknown(), 51 | 52 | internals.schema.common.keys({ 53 | url: Joi.string().uri(), 54 | socket: Joi.string(), 55 | cluster: internals.schema.cluster 56 | }) 57 | .xor('url', 'socket', 'cluster'), 58 | 59 | internals.schema.common.concat(internals.schema.host) 60 | ]); 61 | 62 | 63 | exports.Engine = class CatboxRedis { 64 | 65 | constructor(options = {}) { 66 | 67 | this.settings = Joi.attempt(options, internals.schema.options); 68 | } 69 | 70 | async start() { 71 | 72 | // Skip if already started 73 | 74 | if (this.client) { 75 | return; 76 | } 77 | 78 | // Externally managed clients 79 | 80 | if (this.settings.client) { 81 | this.client = this.settings.client; 82 | return; 83 | } 84 | 85 | // Normalize Redis options 86 | 87 | const redisOptions = Hoek.clone(this.settings); 88 | redisOptions.lazyConnect = !this.settings.cluster; 89 | 90 | for (const key of ['client', 'cluster', 'partition', 'socket', 'url']) { 91 | delete redisOptions[key]; 92 | } 93 | 94 | // Cluster 95 | 96 | if (this.settings.cluster) { 97 | return new Promise((resolve, reject) => { 98 | 99 | this.client = new IoRedis.Cluster(this.settings.cluster, redisOptions); 100 | this.client.once('ready', resolve); 101 | this.client.on('error', reject); 102 | }); 103 | } 104 | 105 | // Single connection 106 | 107 | const client = this._connection(redisOptions); 108 | 109 | client.on('error', () => { 110 | 111 | if (!this.client) { // Failed to connect 112 | client.disconnect(); 113 | } 114 | }); 115 | 116 | await client.connect(); 117 | this.client = client; 118 | } 119 | 120 | _connection(options) { 121 | 122 | if (this.settings.url) { 123 | return new IoRedis(this.settings.url, options); 124 | } 125 | 126 | if (this.settings.socket) { 127 | return new IoRedis(this.settings.socket, options); 128 | } 129 | 130 | return new IoRedis(options); 131 | } 132 | 133 | async stop() { 134 | 135 | if (!this.client) { 136 | return; 137 | } 138 | 139 | try { 140 | if (!this.settings.client) { 141 | this.client.removeAllListeners(); 142 | await this.client.disconnect(); 143 | } 144 | } 145 | finally { 146 | this.client = null; 147 | } 148 | } 149 | 150 | isReady() { 151 | 152 | return this.client?.status === 'ready'; 153 | } 154 | 155 | validateSegmentName(name) { 156 | 157 | if (!name) { 158 | return new Error('Empty string'); 159 | } 160 | 161 | if (name.indexOf('\0') !== -1) { 162 | return new Error('Includes null character'); 163 | } 164 | 165 | return null; 166 | } 167 | 168 | async get(key) { 169 | 170 | if (!this.client) { 171 | throw Error('Connection not started'); 172 | } 173 | 174 | const result = await this.client.get(this.generateKey(key)); 175 | if (!result) { 176 | return null; 177 | } 178 | 179 | try { 180 | var envelope = Bourne.parse(result); 181 | } 182 | catch (ignoreErr) { } // Handled by validation below 183 | 184 | if (!envelope) { 185 | throw Error('Bad envelope content'); 186 | } 187 | 188 | if (!envelope.stored || 189 | !envelope.hasOwnProperty('item')) { 190 | 191 | throw Error('Incorrect envelope structure'); 192 | } 193 | 194 | return envelope; 195 | } 196 | 197 | set(key, value, ttl) { 198 | 199 | if (!this.client) { 200 | throw Error('Connection not started'); 201 | } 202 | 203 | const envelope = { 204 | item: value, 205 | stored: Date.now(), 206 | ttl 207 | }; 208 | 209 | const cacheKey = this.generateKey(key); 210 | const stringifiedEnvelope = JSON.stringify(envelope); 211 | 212 | return this.client.psetex(cacheKey, ttl, stringifiedEnvelope); 213 | } 214 | 215 | drop(key) { 216 | 217 | if (!this.client) { 218 | throw Error('Connection not started'); 219 | } 220 | 221 | return this.client.del(this.generateKey(key)); 222 | } 223 | 224 | generateKey({ id, segment }) { 225 | 226 | const parts = []; 227 | 228 | if (this.settings.partition) { 229 | parts.push(encodeURIComponent(this.settings.partition)); 230 | } 231 | 232 | parts.push(encodeURIComponent(segment)); 233 | parts.push(encodeURIComponent(id)); 234 | 235 | return parts.join(':'); 236 | } 237 | }; 238 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hapi/catbox-redis", 3 | "description": "Redis adapter for catbox", 4 | "version": "7.0.2", 5 | "repository": "git://github.com/hapijs/catbox-redis", 6 | "engines": { 7 | "node": ">=14.0.0" 8 | }, 9 | "main": "lib/index.js", 10 | "types": "lib/index.d.ts", 11 | "files": [ 12 | "lib" 13 | ], 14 | "keywords": [ 15 | "cache", 16 | "catbox", 17 | "redis" 18 | ], 19 | "eslintConfig": { 20 | "extends": [ 21 | "plugin:@hapi/module" 22 | ] 23 | }, 24 | "dependencies": { 25 | "@hapi/bourne": "^3.0.0", 26 | "@hapi/hoek": "^11.0.2", 27 | "ioredis": "^5.0.0", 28 | "joi": "^17.7.1" 29 | }, 30 | "devDependencies": { 31 | "@hapi/catbox": "^12.1.1", 32 | "@hapi/code": "^9.0.3", 33 | "@hapi/eslint-plugin": "^6.0.0", 34 | "@hapi/lab": "^25.1.2", 35 | "@types/node": "^16.18.39", 36 | "redis-parser": "^3.0.0", 37 | "typescript": "^5.1.6" 38 | }, 39 | "scripts": { 40 | "test": "lab -t 100 -a @hapi/code -L -m 15000 -Y", 41 | "test-cov-html": "lab -r html -o coverage.html -a @hapi/code -L" 42 | }, 43 | "license": "BSD-3-Clause" 44 | } 45 | -------------------------------------------------------------------------------- /test/cluster.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Hoek = require('@hapi/hoek'); 4 | const Catbox = require('@hapi/catbox'); 5 | const { Engine: CatboxRedis } = require('..'); 6 | 7 | const Code = require('@hapi/code'); 8 | const Lab = require('@hapi/lab'); 9 | 10 | 11 | const internals = {}; 12 | 13 | 14 | const { describe, it } = exports.lab = Lab.script(); 15 | const expect = Code.expect; 16 | 17 | 18 | describe('Redis Cluster', () => { 19 | 20 | it('creates connection to multiple nodes', async () => { 21 | 22 | const connection = new CatboxRedis({ 23 | cluster: [ 24 | { 25 | host: '127.0.0.1', 26 | port: 7000 27 | }, 28 | { 29 | host: '127.0.0.1', 30 | port: 7001 31 | }, 32 | { 33 | host: '127.0.0.1', 34 | port: 7002 35 | } 36 | ] 37 | }); 38 | 39 | await connection.start(); 40 | expect(connection.isReady()).to.equal(true); 41 | }); 42 | 43 | it('closes the connection', async () => { 44 | 45 | const connection = new CatboxRedis({ 46 | cluster: [ 47 | { 48 | host: '127.0.0.1', 49 | port: 7000 50 | } 51 | ] 52 | }); 53 | 54 | const client = new Catbox.Client(connection); 55 | await client.start(); 56 | expect(client.isReady()).to.equal(true); 57 | await client.stop(); 58 | expect(client.isReady()).to.equal(false); 59 | }); 60 | 61 | it('gets an item after setting it', async () => { 62 | 63 | const connection = new CatboxRedis({ 64 | cluster: [ 65 | { 66 | host: '127.0.0.1', 67 | port: 7000 68 | } 69 | ] 70 | }); 71 | 72 | const client = new Catbox.Client(connection); 73 | await client.start(); 74 | 75 | const key = { id: 'x', segment: 'test' }; 76 | await client.set(key, '123', 5000); 77 | 78 | const result = await client.get(key); 79 | expect(result.item).to.equal('123'); 80 | }); 81 | 82 | it('fails setting an item circular references', async () => { 83 | 84 | const connection = new CatboxRedis({ 85 | cluster: [ 86 | { 87 | host: '127.0.0.1', 88 | port: 7000 89 | } 90 | ] 91 | }); 92 | 93 | const client = new Catbox.Client(connection); 94 | await client.start(); 95 | const key = { id: 'x', segment: 'test' }; 96 | const value = { a: 1 }; 97 | value.b = value; 98 | 99 | await expect(client.set(key, value, 10)).to.reject(/Converting circular structure to JSON/); 100 | }); 101 | 102 | it('ignored starting a connection twice chained', async () => { 103 | 104 | const connection = new CatboxRedis({ 105 | cluster: [ 106 | { 107 | host: '127.0.0.1', 108 | port: 7000 109 | } 110 | ] 111 | }); 112 | 113 | const client = new Catbox.Client(connection); 114 | 115 | await client.start(); 116 | expect(client.isReady()).to.equal(true); 117 | 118 | await client.start(); 119 | expect(client.isReady()).to.equal(true); 120 | }); 121 | 122 | it('returns not found on get when using null key', async () => { 123 | 124 | const connection = new CatboxRedis({ 125 | cluster: [ 126 | { 127 | host: '127.0.0.1', 128 | port: 7000 129 | } 130 | ] 131 | }); 132 | 133 | const client = new Catbox.Client(connection); 134 | await client.start(); 135 | 136 | const result = await client.get(null); 137 | 138 | expect(result).to.equal(null); 139 | }); 140 | 141 | it('returns not found on get when item expired', async () => { 142 | 143 | const connection = new CatboxRedis({ 144 | cluster: [ 145 | { 146 | host: '127.0.0.1', 147 | port: 7000 148 | } 149 | ] 150 | }); 151 | 152 | const client = new Catbox.Client(connection); 153 | await client.start(); 154 | 155 | const key = { id: 'x', segment: 'test' }; 156 | await client.set(key, 'x', 10); 157 | 158 | await Hoek.wait(20); 159 | 160 | const result = await client.get(key); 161 | expect(result).to.equal(null); 162 | }); 163 | 164 | it('returns error on set when using null key', async () => { 165 | 166 | const connection = new CatboxRedis({ 167 | cluster: [ 168 | { 169 | host: '127.0.0.1', 170 | port: 7000 171 | } 172 | ] 173 | }); 174 | 175 | const client = new Catbox.Client(connection); 176 | await client.start(); 177 | 178 | await expect(client.set(null, {}, 1000)).to.reject(); 179 | }); 180 | 181 | it('returns error on get when using invalid key', async () => { 182 | 183 | const connection = new CatboxRedis({ 184 | cluster: [ 185 | { 186 | host: '127.0.0.1', 187 | port: 7000 188 | } 189 | ] 190 | }); 191 | 192 | const client = new Catbox.Client(connection); 193 | await client.start(); 194 | 195 | await expect(client.get({})).to.reject(); 196 | }); 197 | 198 | it('returns error on drop when using invalid key', async () => { 199 | 200 | const connection = new CatboxRedis({ 201 | cluster: [ 202 | { 203 | host: '127.0.0.1', 204 | port: 7000 205 | } 206 | ] 207 | }); 208 | 209 | const client = new Catbox.Client(connection); 210 | await client.start(); 211 | 212 | await expect(client.drop({})).to.reject(); 213 | }); 214 | 215 | it('returns error on set when using invalid key', async () => { 216 | 217 | const connection = new CatboxRedis({ 218 | cluster: [ 219 | { 220 | host: '127.0.0.1', 221 | port: 7000 222 | } 223 | ] 224 | }); 225 | 226 | const client = new Catbox.Client(connection); 227 | await client.start(); 228 | 229 | await expect(client.set({}, {}, 1000)).to.reject(); 230 | }); 231 | 232 | it('ignores set when using non-positive ttl value', async () => { 233 | 234 | const connection = new CatboxRedis({ 235 | cluster: [ 236 | { 237 | host: '127.0.0.1', 238 | port: 7000 239 | } 240 | ] 241 | }); 242 | 243 | const client = new Catbox.Client(connection); 244 | await client.start(); 245 | const key = { id: 'x', segment: 'test' }; 246 | await client.set(key, 'y', 0); 247 | }); 248 | 249 | it('returns error on drop when using null key', async () => { 250 | 251 | const connection = new CatboxRedis({ 252 | cluster: [ 253 | { 254 | host: '127.0.0.1', 255 | port: 7000 256 | } 257 | ] 258 | }); 259 | 260 | const client = new Catbox.Client(connection); 261 | await client.start(); 262 | 263 | await expect(client.drop(null)).to.reject(); 264 | }); 265 | 266 | it('returns error on get when stopped', async () => { 267 | 268 | const connection = new CatboxRedis({ 269 | cluster: [ 270 | { 271 | host: '127.0.0.1', 272 | port: 7000 273 | } 274 | ] 275 | }); 276 | 277 | const client = new Catbox.Client(connection); 278 | await client.stop(); 279 | 280 | const key = { id: 'x', segment: 'test' }; 281 | await expect(client.connection.get(key)).to.reject('Connection not started'); 282 | }); 283 | 284 | it('returns error on set when stopped', async () => { 285 | 286 | const connection = new CatboxRedis({ 287 | cluster: [ 288 | { 289 | host: '127.0.0.1', 290 | port: 7000 291 | } 292 | ] 293 | }); 294 | 295 | const client = new Catbox.Client(connection); 296 | await client.stop(); 297 | 298 | const key = { id: 'x', segment: 'test' }; 299 | expect(() => client.connection.set(key, 'y', 1)).to.throw('Connection not started'); 300 | }); 301 | 302 | it('returns error on drop when stopped', async () => { 303 | 304 | const connection = new CatboxRedis({ 305 | cluster: [ 306 | { 307 | host: '127.0.0.1', 308 | port: 7000 309 | } 310 | ] 311 | }); 312 | 313 | const client = new Catbox.Client(connection); 314 | await client.stop(); 315 | 316 | const key = { id: 'x', segment: 'test' }; 317 | 318 | try { 319 | await client.connection.drop(key); 320 | } 321 | catch (err) { 322 | expect(err.message).to.equal('Connection not started'); 323 | } 324 | }); 325 | 326 | it('returns error on missing segment name', () => { 327 | 328 | const config = { 329 | expiresIn: 50000 330 | }; 331 | 332 | const fn = () => { 333 | 334 | const connection = new CatboxRedis({ 335 | cluster: [ 336 | { 337 | host: '127.0.0.1', 338 | port: 7000 339 | } 340 | ] 341 | }); 342 | 343 | const client = new Catbox.Client(connection); 344 | new Catbox.Policy(config, client, ''); 345 | }; 346 | 347 | expect(fn).to.throw(); 348 | }); 349 | 350 | it('returns error on bad segment name', () => { 351 | 352 | const config = { 353 | expiresIn: 50000 354 | }; 355 | 356 | const fn = () => { 357 | 358 | const connection = new CatboxRedis({ 359 | cluster: [ 360 | { 361 | host: '127.0.0.1', 362 | port: 7000 363 | } 364 | ] 365 | }); 366 | 367 | const client = new Catbox.Client(connection); 368 | new Catbox.Policy(config, client, 'a\0b'); 369 | }; 370 | 371 | expect(fn).to.throw(); 372 | }); 373 | 374 | it('returns error when cache item dropped while stopped', async () => { 375 | 376 | const connection = new CatboxRedis({ 377 | cluster: [ 378 | { 379 | host: '127.0.0.1', 380 | port: 7000 381 | } 382 | ] 383 | }); 384 | 385 | const client = new Catbox.Client(connection); 386 | await client.stop(); 387 | 388 | await expect(client.drop('a')).to.reject(); 389 | }); 390 | 391 | describe('start()', () => { 392 | 393 | it('sets client to when the connection succeeds', async () => { 394 | 395 | const redisCluster = new CatboxRedis({ 396 | cluster: [ 397 | { 398 | host: '127.0.0.1', 399 | port: 7000 400 | } 401 | ] 402 | }); 403 | 404 | await redisCluster.start(); 405 | expect(redisCluster.client).to.exist(); 406 | }); 407 | 408 | it('returns an error when connection fails', async () => { 409 | 410 | const redisCluster = new CatboxRedis({ 411 | cluster: [ 412 | { 413 | host: '127.0.0.20', 414 | port: 27000 415 | } 416 | ] 417 | }); 418 | 419 | await expect(redisCluster.start()).to.reject(); 420 | }); 421 | 422 | it('sends select command when database is provided', async () => { 423 | 424 | const redisCluster = new CatboxRedis({ 425 | cluster: [ 426 | { 427 | host: '127.0.0.1', 428 | port: 7000 429 | } 430 | ], 431 | database: 1 432 | }); 433 | 434 | await redisCluster.start(); 435 | expect(redisCluster.client).to.exist(); 436 | }); 437 | }); 438 | 439 | describe('', () => { 440 | 441 | it('does not stops the client on error post connection', async () => { 442 | 443 | const redisCluster = new CatboxRedis({ 444 | cluster: [ 445 | { 446 | host: '127.0.0.1', 447 | port: 7000 448 | } 449 | ] 450 | }); 451 | 452 | await redisCluster.start(); 453 | expect(redisCluster.client).to.exist(); 454 | 455 | redisCluster.client.emit('error', new Error('injected')); 456 | expect(redisCluster.client).to.exist(); 457 | }); 458 | }); 459 | 460 | describe('isReady()', () => { 461 | 462 | it('returns true when when connected', async () => { 463 | 464 | const redisCluster = new CatboxRedis({ 465 | cluster: [ 466 | { 467 | host: '127.0.0.1', 468 | port: 7000 469 | } 470 | ] 471 | }); 472 | 473 | await redisCluster.start(); 474 | expect(redisCluster.client).to.exist(); 475 | expect(redisCluster.isReady()).to.equal(true); 476 | await redisCluster.stop(); 477 | }); 478 | 479 | it('returns false when stopped', async () => { 480 | 481 | const redisCluster = new CatboxRedis({ 482 | cluster: [ 483 | { 484 | host: '127.0.0.1', 485 | port: 7000 486 | } 487 | ] 488 | }); 489 | 490 | await redisCluster.start(); 491 | expect(redisCluster.client).to.exist(); 492 | expect(redisCluster.isReady()).to.equal(true); 493 | await redisCluster.stop(); 494 | expect(redisCluster.isReady()).to.equal(false); 495 | }); 496 | }); 497 | 498 | describe('validateSegmentName()', () => { 499 | 500 | it('returns an error when the name is empty', () => { 501 | 502 | const redisCluster = new CatboxRedis({ 503 | cluster: [ 504 | { 505 | host: '127.0.0.1', 506 | port: 7000 507 | } 508 | ] 509 | }); 510 | 511 | const result = redisCluster.validateSegmentName(''); 512 | 513 | expect(result).to.be.an.error(); 514 | expect(result.message).to.equal('Empty string'); 515 | }); 516 | 517 | it('returns an error when the name has a null character', () => { 518 | 519 | const redisCluster = new CatboxRedis({ 520 | cluster: [ 521 | { 522 | host: '127.0.0.1', 523 | port: 7000 524 | } 525 | ] 526 | }); 527 | 528 | const result = redisCluster.validateSegmentName('\0test'); 529 | 530 | expect(result).to.be.an.error(); 531 | }); 532 | 533 | it('returns null when there aren\'t any errors', () => { 534 | 535 | const redisCluster = new CatboxRedis({ 536 | cluster: [ 537 | { 538 | host: '127.0.0.1', 539 | port: 7000 540 | } 541 | ] 542 | }); 543 | 544 | const result = redisCluster.validateSegmentName('valid'); 545 | 546 | expect(result).to.not.be.an.error(); 547 | expect(result).to.equal(null); 548 | }); 549 | }); 550 | 551 | describe('get()', () => { 552 | 553 | it('returns a promise that rejects when the connection is closed', async () => { 554 | 555 | const redisCluster = new CatboxRedis({ 556 | cluster: [ 557 | { 558 | host: '127.0.0.1', 559 | port: 7000 560 | } 561 | ] 562 | }); 563 | 564 | try { 565 | await redisCluster.get('test'); 566 | } 567 | catch (err) { 568 | expect(err.message).to.equal('Connection not started'); 569 | } 570 | }); 571 | 572 | it('returns a promise that rejects when there is an error returned from getting an item', async () => { 573 | 574 | const redisCluster = new CatboxRedis({ 575 | cluster: [ 576 | { 577 | host: '127.0.0.1', 578 | port: 7000 579 | } 580 | ] 581 | }); 582 | 583 | redisCluster.client = { 584 | get: function (item) { 585 | 586 | return Promise.reject(Error()); 587 | } 588 | }; 589 | 590 | await expect(redisCluster.get('test')).to.reject(); 591 | }); 592 | 593 | it('returns a promise that rejects when there is an error parsing the result', async () => { 594 | 595 | const redisCluster = new CatboxRedis({ 596 | cluster: [ 597 | { 598 | host: '127.0.0.1', 599 | port: 7000 600 | } 601 | ] 602 | }); 603 | 604 | redisCluster.client = { 605 | 606 | get: function (item) { 607 | 608 | return Promise.resolve('test'); 609 | } 610 | }; 611 | 612 | await expect(redisCluster.get('test')).to.reject('Bad envelope content'); 613 | }); 614 | 615 | it('returns a promise that rejects when there is an error with the envelope structure (stored)', async () => { 616 | 617 | const redisCluster = new CatboxRedis({ 618 | cluster: [ 619 | { 620 | host: '127.0.0.1', 621 | port: 7000 622 | } 623 | ] 624 | }); 625 | 626 | redisCluster.client = { 627 | get: function (item) { 628 | 629 | return Promise.resolve('{ "item": "false" }'); 630 | } 631 | }; 632 | 633 | await expect(redisCluster.get('test')).to.reject('Incorrect envelope structure'); 634 | }); 635 | 636 | it('returns a promise that rejects when there is an error with the envelope structure (item)', async () => { 637 | 638 | const redisCluster = new CatboxRedis({ 639 | cluster: [ 640 | { 641 | host: '127.0.0.1', 642 | port: 7000 643 | } 644 | ] 645 | }); 646 | 647 | redisCluster.client = { 648 | get: function (item) { 649 | 650 | return Promise.resolve('{ "stored": "123" }'); 651 | } 652 | }; 653 | 654 | await expect(redisCluster.get('test')).to.reject('Incorrect envelope structure'); 655 | }); 656 | 657 | it('is able to retrieve an object thats stored when connection is started', async () => { 658 | 659 | const key = { 660 | id: 'test', 661 | segment: 'test' 662 | }; 663 | 664 | const redisCluster = new CatboxRedis({ 665 | cluster: [ 666 | { 667 | host: '127.0.0.1', 668 | port: 7000 669 | } 670 | ], 671 | partition: 'wwwtest' 672 | }); 673 | 674 | await redisCluster.start(); 675 | await redisCluster.set(key, 'myvalue', 200); 676 | const result = await redisCluster.get(key); 677 | expect(result.item).to.equal('myvalue'); 678 | }); 679 | 680 | it('returns null when unable to find the item', async () => { 681 | 682 | const key = { 683 | id: 'notfound', 684 | segment: 'notfound' 685 | }; 686 | 687 | const redisCluster = new CatboxRedis({ 688 | cluster: [ 689 | { 690 | host: '127.0.0.1', 691 | port: 7000 692 | } 693 | ], 694 | partition: 'wwwtest' 695 | }); 696 | 697 | await redisCluster.start(); 698 | const result = await redisCluster.get(key); 699 | expect(result).to.not.exist(); 700 | }); 701 | 702 | it('can store and retrieve falsy values such as int 0', async () => { 703 | 704 | const key = { 705 | id: 'test', 706 | segment: 'test' 707 | }; 708 | 709 | const redisCluster = new CatboxRedis({ 710 | cluster: [ 711 | { 712 | host: '127.0.0.1', 713 | port: 7000 714 | } 715 | ], 716 | partition: 'wwwtest' 717 | }); 718 | 719 | await redisCluster.start(); 720 | await redisCluster.set(key, 0, 200); 721 | const result = await redisCluster.get(key); 722 | expect(result.item).to.equal(0); 723 | }); 724 | 725 | it('can store and retrieve falsy values such as boolean false', async () => { 726 | 727 | const key = { 728 | id: 'test', 729 | segment: 'test' 730 | }; 731 | 732 | const redisCluster = new CatboxRedis({ 733 | cluster: [ 734 | { 735 | host: '127.0.0.1', 736 | port: 7000 737 | } 738 | ], 739 | partition: 'wwwtest' 740 | }); 741 | 742 | await redisCluster.start(); 743 | await redisCluster.set(key, false, 200); 744 | const result = await redisCluster.get(key); 745 | expect(result.item).to.equal(false); 746 | }); 747 | }); 748 | 749 | describe('set()', () => { 750 | 751 | it('returns a promise that rejects when the connection is closed', async () => { 752 | 753 | const redisCluster = new CatboxRedis({ 754 | cluster: [ 755 | { 756 | host: '127.0.0.1', 757 | port: 7000 758 | } 759 | ] 760 | }); 761 | 762 | try { 763 | await redisCluster.set('test1', 'test1', 3600); 764 | } 765 | catch (err) { 766 | expect(err.message).to.equal('Connection not started'); 767 | } 768 | }); 769 | 770 | it('returns a promise that rejects when there is an error returned from setting an item', async () => { 771 | 772 | const redisCluster = new CatboxRedis({ 773 | cluster: [ 774 | { 775 | host: '127.0.0.1', 776 | port: 7000 777 | } 778 | ] 779 | }); 780 | 781 | redisCluster.client = { 782 | psetex: function (key, ttl, value) { 783 | 784 | return Promise.reject(Error()); 785 | } 786 | }; 787 | 788 | await expect(redisCluster.set('test', 'test', 3600)).to.reject(); 789 | }); 790 | }); 791 | 792 | describe('drop()', () => { 793 | 794 | it('returns a promise that rejects when the connection is closed', async () => { 795 | 796 | const redisCluster = new CatboxRedis({ 797 | cluster: [ 798 | { 799 | host: '127.0.0.1', 800 | port: 7000 801 | } 802 | ] 803 | }); 804 | 805 | try { 806 | await redisCluster.drop('test2'); 807 | } 808 | catch (err) { 809 | expect(err.message).to.equal('Connection not started'); 810 | } 811 | }); 812 | 813 | it('deletes the item from redis', async () => { 814 | 815 | const redisCluster = new CatboxRedis({ 816 | cluster: [ 817 | { 818 | host: '127.0.0.1', 819 | port: 7000 820 | } 821 | ] 822 | }); 823 | 824 | redisCluster.client = { 825 | del: function (key) { 826 | 827 | return Promise.resolve(null); 828 | } 829 | }; 830 | 831 | await redisCluster.drop('test'); 832 | }); 833 | }); 834 | 835 | describe('generateKey()', () => { 836 | 837 | it('generates the storage key from a given catbox key', () => { 838 | 839 | const redisCluster = new CatboxRedis({ 840 | cluster: [ 841 | { 842 | host: '127.0.0.1', 843 | port: 7000 844 | } 845 | ], 846 | partition: 'foo' 847 | }); 848 | 849 | const key = { 850 | id: 'bar', 851 | segment: 'baz' 852 | }; 853 | 854 | expect(redisCluster.generateKey(key)).to.equal('foo:baz:bar'); 855 | }); 856 | 857 | it('generates the storage key from a given catbox key without partition', () => { 858 | 859 | const redisCluster = new CatboxRedis({ 860 | cluster: [ 861 | { 862 | host: '127.0.0.1', 863 | port: 7000 864 | } 865 | ] 866 | }); 867 | 868 | const key = { 869 | id: 'bar', 870 | segment: 'baz' 871 | }; 872 | 873 | expect(redisCluster.generateKey(key)).to.equal('baz:bar'); 874 | }); 875 | }); 876 | 877 | describe('stop()', () => { 878 | 879 | it('sets the client to null', async () => { 880 | 881 | const redisCluster = new CatboxRedis({ 882 | cluster: [ 883 | { 884 | host: '127.0.0.1', 885 | port: 7000 886 | } 887 | ] 888 | }); 889 | 890 | await redisCluster.start(); 891 | expect(redisCluster.client).to.exist(); 892 | await redisCluster.stop(); 893 | expect(redisCluster.client).to.not.exist(); 894 | }); 895 | }); 896 | }); 897 | -------------------------------------------------------------------------------- /test/esm.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | 6 | 7 | const { before, describe, it } = exports.lab = Lab.script(); 8 | const expect = Code.expect; 9 | 10 | 11 | describe('import()', () => { 12 | 13 | let CatboxRedis; 14 | 15 | before(async () => { 16 | 17 | CatboxRedis = await import('../lib/index.js'); 18 | }); 19 | 20 | it('exposes all methods and classes as named imports', () => { 21 | 22 | expect(Object.keys(CatboxRedis)).to.equal([ 23 | 'Engine', 24 | 'default' 25 | ]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Net = require('net'); 4 | 5 | const IoRedis = require('ioredis'); 6 | const Catbox = require('@hapi/catbox'); 7 | const Hoek = require('@hapi/hoek'); 8 | const Mock = require('./mock'); 9 | const { Engine: CatboxRedis } = require('..'); 10 | 11 | const Code = require('@hapi/code'); 12 | const Lab = require('@hapi/lab'); 13 | 14 | const internals = {}; 15 | 16 | const { it, describe } = exports.lab = Lab.script(); 17 | const expect = Code.expect; 18 | 19 | describe('Connection', { retry: true }, () => { 20 | 21 | it('creates a new connection', async () => { 22 | 23 | const client = new Catbox.Client(CatboxRedis); 24 | await client.start(); 25 | expect(client.isReady()).to.equal(true); 26 | }); 27 | 28 | it('closes the connection', async () => { 29 | 30 | const client = new Catbox.Client(CatboxRedis); 31 | await client.start(); 32 | expect(client.isReady()).to.equal(true); 33 | await client.stop(); 34 | expect(client.isReady()).to.equal(false); 35 | }); 36 | 37 | it('allow passing client in option', () => { 38 | 39 | return new Promise((resolve, reject) => { 40 | 41 | const redisClient = IoRedis.createClient(); 42 | 43 | let getCalled = false; 44 | const _get = redisClient.get; 45 | redisClient.get = function (...args) { 46 | 47 | getCalled = true; 48 | return _get.apply(redisClient, args); 49 | }; 50 | 51 | redisClient.on('error', reject); 52 | redisClient.once('ready', async () => { 53 | 54 | const client = new Catbox.Client(CatboxRedis, { 55 | client: redisClient 56 | }); 57 | await client.start(); 58 | expect(client.isReady()).to.equal(true); 59 | const key = { id: 'x', segment: 'test' }; 60 | await client.get(key); 61 | expect(getCalled).to.equal(true); 62 | 63 | resolve(); 64 | }); 65 | }); 66 | }); 67 | 68 | it('does not stop provided client in options', async () => { 69 | 70 | const redisClient = IoRedis.createClient(); 71 | 72 | await new Promise((resolve, reject) => { 73 | 74 | redisClient.once('error', reject); 75 | redisClient.once('ready', resolve); 76 | }); 77 | 78 | const client = new Catbox.Client(CatboxRedis, { client: redisClient }); 79 | await client.start(); 80 | expect(client.isReady()).to.equal(true); 81 | await client.stop(); 82 | expect(client.isReady()).to.equal(false); 83 | expect(redisClient.status).to.equal('ready'); 84 | await redisClient.quit(); 85 | }); 86 | 87 | it('gets an item after setting it', async () => { 88 | 89 | const client = new Catbox.Client(CatboxRedis); 90 | await client.start(); 91 | 92 | const key = { id: 'x', segment: 'test' }; 93 | await client.set(key, '123', 500); 94 | 95 | const result = await client.get(key); 96 | expect(result.item).to.equal('123'); 97 | }); 98 | 99 | it('fails setting an item circular references', async () => { 100 | 101 | const client = new Catbox.Client(CatboxRedis); 102 | await client.start(); 103 | const key = { id: 'x', segment: 'test' }; 104 | const value = { a: 1 }; 105 | value.b = value; 106 | 107 | await expect(client.set(key, value, 10)).to.reject(/Converting circular structure to JSON/); 108 | }); 109 | 110 | it('ignored starting a connection twice on same event', () => { 111 | 112 | return new Promise((resolve, reject) => { 113 | 114 | const client = new Catbox.Client(CatboxRedis); 115 | let x = 2; 116 | const start = async () => { 117 | 118 | await client.start(); 119 | expect(client.isReady()).to.equal(true); 120 | --x; 121 | if (!x) { 122 | resolve(); 123 | } 124 | }; 125 | 126 | start(); 127 | start(); 128 | }); 129 | }); 130 | 131 | it('ignored starting a connection twice chained', async () => { 132 | 133 | const client = new Catbox.Client(CatboxRedis); 134 | 135 | await client.start(); 136 | expect(client.isReady()).to.equal(true); 137 | 138 | await client.start(); 139 | expect(client.isReady()).to.equal(true); 140 | }); 141 | 142 | it('returns not found on get when using null key', async () => { 143 | 144 | const client = new Catbox.Client(CatboxRedis); 145 | await client.start(); 146 | 147 | const result = await client.get(null); 148 | 149 | expect(result).to.equal(null); 150 | }); 151 | 152 | it('returns not found on get when item expired', async () => { 153 | 154 | const client = new Catbox.Client(CatboxRedis); 155 | await client.start(); 156 | 157 | const key = { id: 'x', segment: 'test' }; 158 | await client.set(key, 'x', 1); 159 | 160 | await Hoek.wait(2); 161 | const result = await client.get(key); 162 | expect(result).to.equal(null); 163 | }); 164 | 165 | it('errors on set when using null key', async () => { 166 | 167 | const client = new Catbox.Client(CatboxRedis); 168 | await client.start(); 169 | 170 | await expect(client.set(null, {}, 1000)).to.reject(); 171 | }); 172 | 173 | it('errors on get when using invalid key', async () => { 174 | 175 | const client = new Catbox.Client(CatboxRedis); 176 | await client.start(); 177 | 178 | await expect(client.get({})).to.reject(); 179 | }); 180 | 181 | it('errors on drop when using invalid key', async () => { 182 | 183 | const client = new Catbox.Client(CatboxRedis); 184 | await client.start(); 185 | 186 | await expect(client.drop({})).to.reject(); 187 | }); 188 | 189 | it('errors on set when using invalid key', async () => { 190 | 191 | const client = new Catbox.Client(CatboxRedis); 192 | await client.start(); 193 | 194 | await expect(client.set({}, {}, 1000)).to.reject(); 195 | }); 196 | 197 | it('ignores set when using non-positive ttl value', async () => { 198 | 199 | const client = new Catbox.Client(CatboxRedis); 200 | await client.start(); 201 | const key = { id: 'x', segment: 'test' }; 202 | await client.set(key, 'y', 0); 203 | }); 204 | 205 | it('errors on drop when using null key', async () => { 206 | 207 | const client = new Catbox.Client(CatboxRedis); 208 | await client.start(); 209 | 210 | await expect(client.drop(null)).to.reject(); 211 | }); 212 | 213 | it('errors on get when stopped', async () => { 214 | 215 | const client = new Catbox.Client(CatboxRedis); 216 | await client.stop(); 217 | 218 | const key = { id: 'x', segment: 'test' }; 219 | await expect(client.connection.get(key)).to.reject('Connection not started'); 220 | }); 221 | 222 | it('errors on set when stopped', async () => { 223 | 224 | const client = new Catbox.Client(CatboxRedis); 225 | await client.stop(); 226 | 227 | const key = { id: 'x', segment: 'test' }; 228 | expect(() => client.connection.set(key, 'y', 1)).to.throw('Connection not started'); 229 | }); 230 | 231 | it('errors on drop when stopped', async () => { 232 | 233 | const client = new Catbox.Client(CatboxRedis); 234 | await client.stop(); 235 | 236 | const key = { id: 'x', segment: 'test' }; 237 | 238 | try { 239 | await client.connection.drop(key); 240 | } 241 | catch (err) { 242 | expect(err.message).to.equal('Connection not started'); 243 | } 244 | }); 245 | 246 | it('errors on missing segment name', () => { 247 | 248 | const config = { 249 | expiresIn: 50000 250 | }; 251 | const fn = () => { 252 | 253 | const client = new Catbox.Client(CatboxRedis); 254 | new Catbox.Policy(config, client, ''); 255 | }; 256 | 257 | expect(fn).to.throw(Error); 258 | }); 259 | 260 | it('errors on bad segment name', () => { 261 | 262 | const config = { 263 | expiresIn: 50000 264 | }; 265 | const fn = () => { 266 | 267 | const client = new Catbox.Client(CatboxRedis); 268 | new Catbox.Policy(config, client, 'a\0b'); 269 | }; 270 | 271 | expect(fn).to.throw(Error); 272 | }); 273 | 274 | it('errors when cache item dropped while stopped', async () => { 275 | 276 | const client = new Catbox.Client(CatboxRedis); 277 | await client.stop(); 278 | 279 | await expect(client.drop('a')).to.reject(); 280 | }); 281 | 282 | describe('start()', () => { 283 | 284 | it('sets client to when the connection succeeds', async () => { 285 | 286 | const options = { 287 | host: '127.0.0.1', 288 | port: 6379 289 | }; 290 | 291 | const redis = new CatboxRedis(options); 292 | 293 | await redis.start(); 294 | expect(redis.client).to.exist(); 295 | }); 296 | 297 | it('reuses the client when a connection is already started', async () => { 298 | 299 | const options = { 300 | host: '127.0.0.1', 301 | port: 6379 302 | }; 303 | 304 | const redis = new CatboxRedis(options); 305 | 306 | await redis.start(); 307 | const client = redis.client; 308 | 309 | await redis.start(); 310 | expect(client).to.equal(redis.client); 311 | }); 312 | 313 | it('returns an error when connection fails', async () => { 314 | 315 | const options = { 316 | host: '127.0.0.1', 317 | port: 6380 318 | }; 319 | 320 | const redis = new CatboxRedis(options); 321 | 322 | await expect(redis.start()).to.reject(); 323 | 324 | expect(redis.client).to.not.exist(); 325 | }); 326 | 327 | it('sends auth command when password is provided', async () => { 328 | 329 | const options = { 330 | host: '127.0.0.1', 331 | port: 6379, 332 | password: 'wrongpassword' 333 | }; 334 | 335 | const redis = new CatboxRedis(options); 336 | 337 | const warn = console.warn; 338 | let consoleMessage = ''; 339 | console.warn = function (message) { 340 | 341 | consoleMessage += message; 342 | }; 343 | 344 | await redis.start(); 345 | 346 | console.warn = warn; 347 | expect(consoleMessage).to.contain('does not require a password, but a password was supplied'); 348 | }); 349 | 350 | it('fails in error when auth is not correct', async () => { 351 | 352 | const options = { 353 | host: '127.0.0.1', 354 | port: 6378, 355 | password: 'foo' 356 | }; 357 | 358 | const redis = new CatboxRedis(options); 359 | 360 | await expect(redis.start()).to.reject(); 361 | 362 | expect(redis.client).to.not.exist(); 363 | }); 364 | 365 | it('success when auth is correct', async () => { 366 | 367 | const options = { 368 | host: '127.0.0.1', 369 | port: 6378, 370 | password: 'secret' 371 | }; 372 | 373 | const redis = new CatboxRedis(options); 374 | 375 | await redis.start(); 376 | expect(redis.client).to.exist(); 377 | }); 378 | 379 | it('sends select command when database is provided', async () => { 380 | 381 | const options = { 382 | host: '127.0.0.1', 383 | port: 6379, 384 | database: 1 385 | }; 386 | 387 | const redis = new CatboxRedis(options); 388 | 389 | await redis.start(); 390 | expect(redis.client).to.exist(); 391 | }); 392 | 393 | it('connects to a unix domain socket when one is provided', () => { 394 | 395 | const socketPath = '/tmp/catbox-redis.sock'; 396 | const promise = new Promise((resolve, reject) => { 397 | 398 | let connected = false; 399 | const server = new Net.createServer((socket) => { 400 | 401 | connected = true; 402 | socket.destroy(); 403 | }); 404 | 405 | server.once('error', reject); 406 | server.listen(socketPath, async () => { 407 | 408 | const redis = new CatboxRedis({ socket: socketPath }); 409 | await expect(redis.start()).to.reject('Connection is closed.'); 410 | expect(connected).to.equal(true); 411 | server.close(resolve); 412 | }); 413 | }); 414 | 415 | return promise; 416 | }); 417 | 418 | it('connects via a Redis URL when one is provided', async () => { 419 | 420 | const options = { 421 | url: 'redis://127.0.0.1:6379' 422 | }; 423 | 424 | const redis = new CatboxRedis(options); 425 | 426 | await redis.start(); 427 | expect(redis.client).to.exist(); 428 | }); 429 | 430 | describe('', () => { 431 | 432 | it('connects to a sentinel cluster', async () => { 433 | 434 | const sentinel = new Mock(27379, (argv) => { 435 | 436 | if (argv[0] === 'sentinel' && argv[1] === 'get-master-addr-by-name') { 437 | return ['127.0.0.1', '6379']; 438 | } 439 | }); 440 | 441 | sentinel.once('connect', () => { 442 | 443 | sentinel.disconnect(); 444 | }); 445 | 446 | const options = { 447 | sentinels: [ 448 | { 449 | host: '127.0.0.1', 450 | port: 27379 451 | }, 452 | { 453 | host: '127.0.0.2', 454 | port: 27379 455 | } 456 | ], 457 | sentinelName: 'mymaster' 458 | }; 459 | 460 | const redis = new CatboxRedis(options); 461 | 462 | await redis.start(); 463 | const client = redis.client; 464 | expect(client).to.exist(); 465 | expect(client.connector.options.sentinels).to.equal(options.sentinels); 466 | expect(client.connector.options.name).to.equal(options.sentinelName); 467 | }); 468 | }); 469 | 470 | it('does not stops the client on error post connection', async () => { 471 | 472 | const options = { 473 | host: '127.0.0.1', 474 | port: 6379 475 | }; 476 | 477 | const redis = new CatboxRedis(options); 478 | 479 | await redis.start(); 480 | expect(redis.client).to.exist(); 481 | 482 | redis.client.emit('error', new Error('injected')); 483 | expect(redis.client).to.exist(); 484 | }); 485 | }); 486 | 487 | describe('isReady()', () => { 488 | 489 | it('returns true when when connected', async () => { 490 | 491 | const options = { 492 | host: '127.0.0.1', 493 | port: 6379 494 | }; 495 | 496 | const redis = new CatboxRedis(options); 497 | 498 | await redis.start(); 499 | expect(redis.client).to.exist(); 500 | expect(redis.isReady()).to.equal(true); 501 | await redis.stop(); 502 | }); 503 | 504 | it('returns false when stopped', async () => { 505 | 506 | const options = { 507 | host: '127.0.0.1', 508 | port: 6379 509 | }; 510 | 511 | const redis = new CatboxRedis(options); 512 | 513 | await redis.start(); 514 | expect(redis.client).to.exist(); 515 | expect(redis.isReady()).to.equal(true); 516 | await redis.stop(); 517 | expect(redis.isReady()).to.equal(false); 518 | }); 519 | }); 520 | 521 | describe('validateSegmentName()', () => { 522 | 523 | it('returns an error when the name is empty', () => { 524 | 525 | const options = { 526 | host: '127.0.0.1', 527 | port: 6379 528 | }; 529 | 530 | const redis = new CatboxRedis(options); 531 | 532 | const result = redis.validateSegmentName(''); 533 | 534 | expect(result).to.be.instanceOf(Error); 535 | expect(result.message).to.equal('Empty string'); 536 | }); 537 | 538 | it('returns an error when the name has a null character', () => { 539 | 540 | const options = { 541 | host: '127.0.0.1', 542 | port: 6379 543 | }; 544 | 545 | const redis = new CatboxRedis(options); 546 | 547 | const result = redis.validateSegmentName('\0test'); 548 | 549 | expect(result).to.be.instanceOf(Error); 550 | }); 551 | 552 | it('returns null when there aren\'t any errors', () => { 553 | 554 | const options = { 555 | host: '127.0.0.1', 556 | port: 6379 557 | }; 558 | 559 | const redis = new CatboxRedis(options); 560 | 561 | const result = redis.validateSegmentName('valid'); 562 | 563 | expect(result).to.not.be.instanceOf(Error); 564 | expect(result).to.equal(null); 565 | }); 566 | }); 567 | 568 | describe('get()', () => { 569 | 570 | it('returns a promise that rejects when the connection is closed', async () => { 571 | 572 | const options = { 573 | host: '127.0.0.1', 574 | port: 6379 575 | }; 576 | 577 | const redis = new CatboxRedis(options); 578 | 579 | try { 580 | await redis.get('test'); 581 | } 582 | catch (err) { 583 | expect(err.message).to.equal('Connection not started'); 584 | } 585 | }); 586 | 587 | it('returns a promise that rejects when there is an error returned from getting an item', async () => { 588 | 589 | const options = { 590 | host: '127.0.0.1', 591 | port: 6379 592 | }; 593 | 594 | const redis = new CatboxRedis(options); 595 | redis.client = { 596 | get: function (item) { 597 | 598 | return Promise.reject(Error()); 599 | } 600 | }; 601 | 602 | await expect(redis.get('test')).to.reject(); 603 | }); 604 | 605 | it('returns a promise that rejects when there is an error parsing the result', async () => { 606 | 607 | const options = { 608 | host: '127.0.0.1', 609 | port: 6379 610 | }; 611 | 612 | const redis = new CatboxRedis(options); 613 | redis.client = { 614 | 615 | get: function (item) { 616 | 617 | return Promise.resolve('test'); 618 | } 619 | }; 620 | 621 | await expect(redis.get('test')).to.reject('Bad envelope content'); 622 | }); 623 | 624 | it('returns a promise that rejects when there is an error with the envelope structure (stored)', async () => { 625 | 626 | const options = { 627 | host: '127.0.0.1', 628 | port: 6379 629 | }; 630 | 631 | const redis = new CatboxRedis(options); 632 | redis.client = { 633 | get: function (item) { 634 | 635 | return Promise.resolve('{ "item": "false" }'); 636 | } 637 | }; 638 | 639 | await expect(redis.get('test')).to.reject('Incorrect envelope structure'); 640 | }); 641 | 642 | it('returns a promise that rejects when there is an error with the envelope structure (item)', async () => { 643 | 644 | const options = { 645 | host: '127.0.0.1', 646 | port: 6379 647 | }; 648 | 649 | const redis = new CatboxRedis(options); 650 | redis.client = { 651 | get: function (item) { 652 | 653 | return Promise.resolve('{ "stored": "123" }'); 654 | } 655 | }; 656 | 657 | await expect(redis.get('test')).to.reject('Incorrect envelope structure'); 658 | }); 659 | 660 | it('is able to retrieve an object thats stored when connection is started', async () => { 661 | 662 | const options = { 663 | host: '127.0.0.1', 664 | port: 6379, 665 | partition: 'wwwtest' 666 | }; 667 | const key = { 668 | id: 'test', 669 | segment: 'test' 670 | }; 671 | 672 | const redis = new CatboxRedis(options); 673 | await redis.start(); 674 | await redis.set(key, 'myvalue', 200); 675 | const result = await redis.get(key); 676 | expect(result.item).to.equal('myvalue'); 677 | }); 678 | 679 | it('returns null when unable to find the item', async () => { 680 | 681 | const options = { 682 | host: '127.0.0.1', 683 | port: 6379, 684 | partition: 'wwwtest' 685 | }; 686 | const key = { 687 | id: 'notfound', 688 | segment: 'notfound' 689 | }; 690 | 691 | const redis = new CatboxRedis(options); 692 | await redis.start(); 693 | const result = await redis.get(key); 694 | expect(result).to.not.exist(); 695 | }); 696 | 697 | it('can store and retrieve falsy values such as int 0', async () => { 698 | 699 | const options = { 700 | host: '127.0.0.1', 701 | port: 6379, 702 | partition: 'wwwtest' 703 | }; 704 | const key = { 705 | id: 'test', 706 | segment: 'test' 707 | }; 708 | 709 | const redis = new CatboxRedis(options); 710 | await redis.start(); 711 | await redis.set(key, 0, 200); 712 | const result = await redis.get(key); 713 | expect(result.item).to.equal(0); 714 | }); 715 | 716 | it('can store and retrieve falsy values such as boolean false', async () => { 717 | 718 | const options = { 719 | host: '127.0.0.1', 720 | port: 6379, 721 | partition: 'wwwtest' 722 | }; 723 | const key = { 724 | id: 'test', 725 | segment: 'test' 726 | }; 727 | 728 | const redis = new CatboxRedis(options); 729 | await redis.start(); 730 | await redis.set(key, false, 200); 731 | const result = await redis.get(key); 732 | expect(result.item).to.equal(false); 733 | }); 734 | }); 735 | 736 | describe('set()', () => { 737 | 738 | it('returns a promise that rejects when the connection is closed', async () => { 739 | 740 | const options = { 741 | host: '127.0.0.1', 742 | port: 6379 743 | }; 744 | 745 | const redis = new CatboxRedis(options); 746 | 747 | try { 748 | await redis.set('test1', 'test1', 3600); 749 | } 750 | catch (err) { 751 | expect(err.message).to.equal('Connection not started'); 752 | } 753 | }); 754 | 755 | it('returns a promise that rejects when there is an error returned from setting an item', async () => { 756 | 757 | const options = { 758 | host: '127.0.0.1', 759 | port: 6379 760 | }; 761 | 762 | const redis = new CatboxRedis(options); 763 | redis.client = { 764 | psetex: function (key, ttls, value) { 765 | 766 | return Promise.reject(Error()); 767 | } 768 | }; 769 | 770 | await expect(redis.set('test', 'test', 3600)).to.reject(); 771 | }); 772 | }); 773 | 774 | describe('drop()', () => { 775 | 776 | it('returns a promise that rejects when the connection is closed', async () => { 777 | 778 | const options = { 779 | host: '127.0.0.1', 780 | port: 6379 781 | }; 782 | 783 | const redis = new CatboxRedis(options); 784 | 785 | try { 786 | await redis.drop('test2'); 787 | } 788 | catch (err) { 789 | expect(err.message).to.equal('Connection not started'); 790 | } 791 | }); 792 | 793 | it('deletes the item from redis', async () => { 794 | 795 | const options = { 796 | host: '127.0.0.1', 797 | port: 6379 798 | }; 799 | 800 | const redis = new CatboxRedis(options); 801 | redis.client = { 802 | del: function (key) { 803 | 804 | return Promise.resolve(null); 805 | } 806 | }; 807 | 808 | await redis.drop('test'); 809 | }); 810 | }); 811 | 812 | describe('generateKey()', () => { 813 | 814 | it('generates the storage key from a given catbox key', () => { 815 | 816 | const options = { 817 | partition: 'foo' 818 | }; 819 | 820 | const redis = new CatboxRedis(options); 821 | 822 | const key = { 823 | id: 'bar', 824 | segment: 'baz' 825 | }; 826 | 827 | expect(redis.generateKey(key)).to.equal('foo:baz:bar'); 828 | }); 829 | 830 | it('generates the storage key from a given catbox key without partition', () => { 831 | 832 | const options = {}; 833 | 834 | const redis = new CatboxRedis(options); 835 | 836 | const key = { 837 | id: 'bar', 838 | segment: 'baz' 839 | }; 840 | 841 | expect(redis.generateKey(key)).to.equal('baz:bar'); 842 | }); 843 | }); 844 | 845 | describe('stop()', () => { 846 | 847 | it('sets the client to null', async () => { 848 | 849 | const options = { 850 | host: '127.0.0.1', 851 | port: 6379 852 | }; 853 | 854 | const redis = new CatboxRedis(options); 855 | 856 | await redis.start(); 857 | expect(redis.client).to.exist(); 858 | await redis.stop(); 859 | expect(redis.client).to.not.exist(); 860 | }); 861 | }); 862 | }); 863 | -------------------------------------------------------------------------------- /test/mock.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Adapted from https://github.com/luin/ioredis 4 | // Copyright (c) 2015-2019 Zihua Li - MIT Licensed 5 | 6 | const EventEmitter = require('events').EventEmitter; 7 | const Net = require('net'); 8 | 9 | const Parser = require('redis-parser'); 10 | 11 | 12 | const internals = {}; 13 | 14 | 15 | module.exports = internals.MockServer = class extends EventEmitter { 16 | 17 | constructor(port, handler) { 18 | 19 | super(); 20 | 21 | this.REDIS_OK = '+OK'; 22 | 23 | this.port = port; 24 | this.handler = handler; 25 | 26 | this.connect(); 27 | } 28 | 29 | connect() { 30 | 31 | this.socket = Net.createServer(); 32 | 33 | this.socket.on('connection', (socket) => { 34 | 35 | process.nextTick(() => this.emit('connect', socket)); 36 | 37 | const parser = new Parser({ 38 | returnBuffers: true, 39 | returnReply: (reply) => { 40 | 41 | reply = this.convertBufferToString(reply); 42 | this.write(socket, this.handler?.(reply)); 43 | }, 44 | returnError: function () {} 45 | }); 46 | 47 | socket.on('end', function () { 48 | 49 | this.emit('disconnect', socket); 50 | }); 51 | 52 | socket.on('data', (data) => { 53 | 54 | parser.execute(data); 55 | }); 56 | }); 57 | 58 | this.socket.listen(this.port); 59 | } 60 | 61 | write(c, input) { 62 | 63 | const convert = function (str, data) { 64 | 65 | let result; 66 | 67 | if (typeof data === 'undefined') { 68 | data = internals.MockServer.REDIS_OK; 69 | } 70 | 71 | if (data === internals.MockServer.REDIS_OK) { 72 | result = '+OK\r\n'; 73 | } 74 | else if (data instanceof Error) { 75 | result = '-' + data.message + '\r\n'; 76 | } 77 | else if (Array.isArray(data)) { 78 | result = '*' + data.length + '\r\n'; 79 | data.forEach((item) => { 80 | 81 | result += convert(str, item); 82 | }); 83 | } 84 | else if (typeof data === 'number') { 85 | result = ':' + data + '\r\n'; 86 | } 87 | else if (data === null) { 88 | result = '$-1\r\n'; 89 | } 90 | else { 91 | data = data.toString(); 92 | result = '$' + data.length + '\r\n'; 93 | result += data + '\r\n'; 94 | } 95 | 96 | return str + result; 97 | }; 98 | 99 | if (c.writable) { 100 | c.write(convert('', input)); 101 | } 102 | } 103 | 104 | convertBufferToString(value, encoding) { 105 | 106 | if (value instanceof Buffer) { 107 | return value.toString(encoding); 108 | } 109 | 110 | if (Array.isArray(value)) { 111 | const length = value.length; 112 | const res = Array(length); 113 | 114 | for (let i = 0; i < length; ++i) { 115 | res[i] = value[i] instanceof Buffer && encoding === 'utf8' 116 | ? value[i].toString() 117 | : this.convertBufferToString(value[i], encoding); 118 | } 119 | 120 | return res; 121 | } 122 | 123 | return value; 124 | } 125 | 126 | disconnect() { 127 | 128 | this.socket.close(); 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /test/types.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from '..'; 2 | import Redis, { Cluster } from 'ioredis'; 3 | 4 | async function test() { 5 | const cache = new Engine({ 6 | client: new Redis(), 7 | host: 'localhost', 8 | partition: 'test', 9 | port: 2018, 10 | }); 11 | 12 | await cache.start() 13 | 14 | cache.get({ 15 | segment: 'test', 16 | id: 'test', 17 | }); 18 | 19 | cache.set({ 20 | segment: 'test', 21 | id: 'test', 22 | }, 'test', 123); 23 | 24 | 25 | new Engine({ 26 | client: new Cluster([ 27 | { 28 | host: '127.0.0.1', 29 | port: 27379 30 | }, 31 | { 32 | host: '127.0.0.2', 33 | port: 27379 34 | } 35 | ]) 36 | }); 37 | } 38 | 39 | test(); 40 | --------------------------------------------------------------------------------