├── .jshintignore ├── .gitignore ├── Makefile ├── hash-dist ├── histogram.plt ├── run.sh └── generate.js ├── .circleci └── config.yml ├── package.json ├── .jshintrc ├── index.js ├── README.md └── tests └── index-test.js /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | hash-dist/results.txt 3 | hash-dist/results.png 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test lint hash-dist 2 | 3 | test: 4 | ./node_modules/.bin/mocha --exit --ui bdd --reporter spec tests 5 | 6 | lint: 7 | ./node_modules/.bin/jshint ./ 8 | 9 | hash-dist: 10 | ./hash-dist/run.sh 11 | -------------------------------------------------------------------------------- /hash-dist/histogram.plt: -------------------------------------------------------------------------------- 1 | set term png 2 | set output 'results.png' 3 | 4 | set key off 5 | set border 3 6 | set style fill solid 1.0 noborder 7 | 8 | bin_width=1 9 | bin(x,width)=width*floor(x/width) 10 | 11 | plot 'results.txt' using (bin($1,bin_width)):(1.0) smooth freq with boxes -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.9.1-stretch 6 | - image: redis:3.2 7 | steps: 8 | - checkout 9 | - run: 10 | name: install-npm 11 | command: npm install 12 | - save_cache: 13 | key: dependency-cache-{{ checksum "package.json" }} 14 | paths: 15 | - ./node_modules 16 | - run: 17 | name: test 18 | command: npm test 19 | -------------------------------------------------------------------------------- /hash-dist/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | 4 | command -v gnuplot >/dev/null 2>&1 || { 5 | echo >&2 '`gnuplot` command is not installed.' 6 | echo >&2 'Run `brew install gnuplot` and retry.' 7 | exit 1 8 | } 9 | 10 | # Ensure the script runs in its working directory 11 | pushd "$(dirname "$0")" 12 | 13 | # Generate random sample results and save to a file 14 | node generate.js > results.txt 15 | 16 | # Create a graph from the random sample data 17 | gnuplot histogram.plt 18 | 19 | # Show the resulting graph in QuickLook on OS X 20 | qlmanage -p "results.png" >& /dev/null & 21 | 22 | popd 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-rollout", 3 | "version": "1.1.0", 4 | "description": "feature rollout management", 5 | "author": "Ali Faiz & Dustin Diaz", 6 | "license": "MIT", 7 | "main": "index.js", 8 | "scripts": { 9 | "test": "make test" 10 | }, 11 | "keywords": [ 12 | "rollout", 13 | "features" 14 | ], 15 | "dependencies": { 16 | "bluebird": "^3.5.1" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/mix/node-rollout.git" 21 | }, 22 | "devDependencies": { 23 | "redis": "~2.8.0", 24 | "mocha": "~5.2.0", 25 | "chai": "~4.2.0", 26 | "sinon": "~7.1.1", 27 | "sinon-chai": "~3.3.0", 28 | "chai-as-promised": "~7.1.1", 29 | "jshint": "~2.9.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /hash-dist/generate.js: -------------------------------------------------------------------------------- 1 | var rollout = require('../index'); 2 | var subject = rollout(); 3 | 4 | function randomString(length) { 5 | var resultChars = []; 6 | var possibleChars = "abcdefghijklmnopqrstuvwxyz0123456789"; 7 | var randomIndex; 8 | 9 | for (var i = 0; i < length; i++) { 10 | randomIndex = Math.floor(Math.random() * possibleChars.length); 11 | resultChars.push(possibleChars.charAt(randomIndex)); 12 | } 13 | 14 | return resultChars.join(''); 15 | } 16 | 17 | function randomId() { 18 | var randomLength = 10 + Math.floor(Math.random() * 20); 19 | return randomString(randomLength); 20 | } 21 | 22 | var n = 100000; 23 | var sample 24 | while (n) { 25 | sample = subject.val_to_percent(randomId()); 26 | process.stdout.write(sample + '\n'); 27 | n--; 28 | } 29 | 30 | process.exit(); 31 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "require" 4 | , "provide" 5 | , "module" 6 | , "exports" 7 | , "process" 8 | , "describe" 9 | , "context" 10 | , "it" 11 | , "beforeEach" 12 | , "afterEach" 13 | , "-Promise" 14 | ] 15 | , "indent": 2 16 | , "maxdepth": 6 17 | , "maxlen": 120 18 | , "bitwise": false 19 | , "curly": false 20 | , "eqeqeq": false 21 | , "forin": false 22 | , "immed": false 23 | , "latedef": false 24 | , "newcap": true 25 | , "noarg": false 26 | , "noempty": true 27 | , "nonew": false 28 | , "plusplus": false 29 | , "quotmark": "single" 30 | , "regexp": false 31 | , "undef": true 32 | , "unused": "vars" 33 | , "strict": false 34 | , "trailing": true 35 | , "asi": true 36 | , "boss": true 37 | , "eqnull": true 38 | , "es5": false 39 | , "esnext": true 40 | , "evil": true 41 | , "expr": true 42 | , "funcscope": false 43 | , "globalstrict": false 44 | , "iterator": false 45 | , "lastsemic": true 46 | , "laxbreak": true 47 | , "laxcomma": true 48 | , "loopfunc": true 49 | , "multistr": false 50 | , "onecase": false 51 | , "proto": false 52 | , "regexdash": false 53 | , "scripturl": true 54 | , "smarttabs": true 55 | , "shadow": false 56 | , "sub": true 57 | , "supernew": false 58 | , "validthis": true 59 | , "browser": true 60 | , "nonstandard": true 61 | , "nomen": false 62 | , "onevar": false 63 | , "passfail": false 64 | , "devel": false 65 | } 66 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto') 2 | var Promise = require('bluebird') 3 | 4 | module.exports = function (client, options) { 5 | return new Rollout(client, options) 6 | } 7 | 8 | function Rollout(client, options) { 9 | if (client && client.clientFactory) { 10 | this.clientFactory = client.clientFactory 11 | } else { 12 | this.clientFactory = function () { 13 | return client 14 | } 15 | } 16 | if (options && options.prefix) { 17 | this.prefix = options.prefix 18 | } 19 | this._handlers = {} 20 | } 21 | 22 | Rollout.prototype.handler = function (key, modifiers) { 23 | this._handlers[key] = modifiers 24 | var configPercentages = [] 25 | var configKeys = Object.keys(modifiers).map(function (modName) { 26 | configPercentages.push(modifiers[modName].percentage) 27 | return this.generate_key(key, modName) 28 | }.bind(this)) 29 | return getRedisKeys(this.clientFactory(), configKeys) 30 | .then(function(persistentPercentages) { 31 | var persistKeys = [] 32 | persistentPercentages.forEach(function (p, i) { 33 | if (p === null) { 34 | p = normalizePercentageRange(configPercentages[i]) 35 | persistKeys.push(configKeys[i], JSON.stringify(p)) 36 | persistentPercentages[i] = p 37 | } 38 | }) 39 | if (persistKeys.length) { 40 | return setRedisKeys(this.clientFactory(), persistKeys) 41 | .then(function() { 42 | return persistentPercentages 43 | }) 44 | } 45 | return persistentPercentages 46 | }.bind(this)) 47 | } 48 | 49 | Rollout.prototype.multi = function (keys) { 50 | var multi = this.clientFactory().multi() 51 | // Accumulate get calls into a single "multi" query 52 | var promises = keys.map(function (k) { 53 | return this.get(k[0], k[1], k[2], multi).reflect() 54 | }.bind(this)) 55 | // Perform the batch query 56 | return new Promise(function (resolve, reject) { 57 | multi.exec(promiseCallback(resolve, reject)) 58 | }) 59 | .then(function () { 60 | return Promise.all(promises) 61 | }) 62 | } 63 | 64 | Rollout.prototype.get = function (key, id, opt_values, multi) { 65 | opt_values = opt_values || { id: id } 66 | opt_values.id = opt_values.id || id 67 | var modifiers = this._handlers[key] 68 | var keys = Object.keys(modifiers).map(this.generate_key.bind(this, key)) 69 | var likely = this.val_to_percent(key + id) 70 | return getRedisKeys(multi || this.clientFactory(), keys) 71 | .then(function (percentages) { 72 | var i = 0 73 | var deferreds = [] 74 | var output 75 | var percentage 76 | for (var modName in modifiers) { 77 | percentage = percentages[i++] 78 | // Redis stringifies everything, so ranges must be reified 79 | if (typeof percentage === 'string') { 80 | percentage = JSON.parse(percentage) 81 | } 82 | // in the circumstance that the key is not found, default to original value 83 | if (percentage === null) { 84 | percentage = normalizePercentageRange(modifiers[modName].percentage) 85 | } 86 | if (isPercentageInRange(likely, percentage)) { 87 | if (!modifiers[modName].condition) { 88 | modifiers[modName].condition = defaultCondition 89 | } 90 | try { 91 | output = modifiers[modName].condition(opt_values[modName]) 92 | } catch (err) { 93 | console.warn('rollout key[' + key + '] mod[' + modName + '] condition threw:', err) 94 | continue 95 | } 96 | if (output) { 97 | if (typeof output.then === 'function') { 98 | // Normalize thenable to Bluebird Promise 99 | // Reflect the Promise to coalesce rejections 100 | output = Promise.resolve(output).reflect() 101 | output.handlerModifier = modName 102 | deferreds.push(output) 103 | } else { 104 | return modName 105 | } 106 | } 107 | } 108 | } 109 | if (deferreds.length) { 110 | return Promise.all(deferreds) 111 | .then(function (results) { 112 | var resultPromise, resultValue 113 | for (var i = 0, len = results.length; i < len; i++) { 114 | resultPromise = results[i] 115 | // Treat rejected conditions as inapplicable modifiers 116 | if (resultPromise.isFulfilled()) { 117 | resultValue = resultPromise.value() 118 | // Treat resolved conditions with truthy values as affirmative 119 | if (resultValue) { 120 | return deferreds[i].handlerModifier 121 | } 122 | } 123 | } 124 | return Promise.reject() 125 | }) 126 | } 127 | throw new Error('Not inclusive of any partition for key[' + key + '] id[' + id + ']') 128 | }) 129 | } 130 | 131 | Rollout.prototype.update = function (key, modifierPercentages) { 132 | var persistKeys = [] 133 | var modName 134 | var percentage 135 | for (modName in modifierPercentages) { 136 | percentage = normalizePercentageRange(modifierPercentages[modName]) 137 | persistKeys.push(this.generate_key(key, modName), JSON.stringify(percentage)) 138 | } 139 | return setRedisKeys(this.clientFactory(), persistKeys) 140 | } 141 | 142 | Rollout.prototype.modifiers = function (handlerName) { 143 | var modifiers = this._handlers[handlerName] 144 | var keys = [] 145 | var modNames = [] 146 | var modName 147 | for (modName in modifiers) { 148 | keys.push(this.generate_key(handlerName, modName)) 149 | modNames.push(modName) 150 | } 151 | return getRedisKeys(this.clientFactory(), keys) 152 | .then(function (percentages) { 153 | var modPercentages = {} 154 | var i = 0 155 | var percentage 156 | for (modName in modifiers) { 157 | percentage = percentages[i++] 158 | // Redis stringifies everything, so ranges must be reified 159 | if (typeof percentage === 'string') { 160 | percentage = JSON.parse(percentage) 161 | } 162 | // in the circumstance that the key is not found, default to original value 163 | if (percentage === null) { 164 | percentage = normalizePercentageRange(modifiers[modName].percentage) 165 | } 166 | modPercentages[modName] = percentage 167 | } 168 | return modPercentages 169 | }) 170 | } 171 | 172 | Rollout.prototype.handlers = function () { 173 | return Promise.resolve(Object.keys(this._handlers)) 174 | } 175 | 176 | Rollout.prototype.val_to_percent = function (text) { 177 | var n = crypto.createHash('md5').update(text).digest('hex') 178 | n = n.slice(0, n.length/2) 179 | return parseInt(n, 16) / parseInt(n.split('').map(function () { return 'f' }).join(''), 16) * 100 180 | } 181 | 182 | Rollout.prototype.generate_key = function (key, modName) { 183 | return (this.prefix ? this.prefix + ':' : '') + key + ':' + modName 184 | } 185 | 186 | function defaultCondition() { 187 | return true 188 | } 189 | 190 | function clampPercentage(val) { 191 | return Math.max(0, Math.min(100, +(val || 0))) 192 | } 193 | 194 | function normalizePercentageRange(val) { 195 | if (val && typeof val === 'object') { 196 | return { 197 | min: clampPercentage(val.min), 198 | max: clampPercentage(val.max) 199 | } 200 | } 201 | return clampPercentage(val) 202 | } 203 | 204 | function isPercentageInRange(val, range) { 205 | if (range && typeof range === 'object') { 206 | return val > range.min && val <= range.max 207 | } 208 | return val < range 209 | } 210 | 211 | function getRedisKeys(client, keys) { 212 | return new Promise(function (resolve, reject) { 213 | client.mget(keys, promiseCallback(resolve, reject)) 214 | }) 215 | } 216 | 217 | function setRedisKeys(client, keys) { 218 | return new Promise(function (resolve, reject) { 219 | client.mset(keys, promiseCallback(resolve, reject)) 220 | }) 221 | } 222 | 223 | function promiseCallback(resolve, reject) { 224 | return function (err, result) { 225 | if (err) { 226 | return reject(err) 227 | } 228 | resolve(result) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Node Rollout 2 | [![CircleCI](https://circleci.com/gh/mix/node-rollout.svg?style=svg)](https://circleci.com/gh/mix/node-rollout) 3 | [![Maintainability](https://api.codeclimate.com/v1/badges/1cf0304dee9b1f264a64/maintainability)](https://codeclimate.com/github/mix/node-rollout/maintainability) [![Greenkeeper badge](https://badges.greenkeeper.io/mix/node-rollout.svg)](https://greenkeeper.io/) 4 | Feature rollout management for Node.js built on Redis 5 | 6 | ### Example Usage 7 | 8 | #### Installation 9 | 10 | ``` sh 11 | npm install node-rollout --save 12 | ``` 13 | 14 | #### Basic Configuration 15 | 16 | ``` js 17 | // basic_configuration.js 18 | var client = require('redis').createClient() 19 | var rollout = require('node-rollout')(client) 20 | rollout.handler('new_homepage', { 21 | // 1% of regular users 22 | id: { 23 | percentage: 1 24 | }, 25 | // All users with the company email 26 | employee: { 27 | percentage: 100, 28 | condition: function (val) { 29 | return /@company-email\.com$/.test(val) 30 | } 31 | }, 32 | // 50% of users in San Francisco 33 | geo_sf: { 34 | percentage: 50, 35 | condition: function (val) { 36 | return geolib.getDistance([val.lat, val.lon], [37.768, -122.426], 'miles') < 7 37 | } 38 | }, 39 | // Asynchronous database lookup 40 | admin: { 41 | percentage: 100, 42 | condition: function (val) { 43 | return db.lookupUser(val) 44 | .then(function (user) { 45 | return user.isAdmin() 46 | }) 47 | } 48 | } 49 | }) 50 | 51 | module.exports = rollout 52 | ``` 53 | 54 | ``` js 55 | // A typical Express app demonstrating rollout flags 56 | ... 57 | var rollout = require('./basic_configuration') 58 | 59 | app.get('/', new_homepage, old_homepage) 60 | 61 | function new_home_page(req, res, next) { 62 | rollout.get('new_homepage', req.current_user.id, { 63 | employee: req.current_user.email, 64 | geo: [req.current_user.lat, req.current_user.lon], 65 | admin: req.current_user.id 66 | }) 67 | .then(function () { 68 | res.render('home/new-index') 69 | }) 70 | .catch(next) 71 | } 72 | 73 | function old_home_page (req, res, next) { 74 | res.render('home/index') 75 | } 76 | 77 | ``` 78 | 79 | #### Experiment groups 80 | 81 | ``` js 82 | // experiment_groups_configuration.js 83 | var client = require('redis').createClient() 84 | var rollout = require('node-rollout')(client) 85 | // An experiment with 3 randomly-assigned groups 86 | rollout.handler('homepage_variant', { 87 | versionA: { 88 | percentage: { min: 0, max: 33 } 89 | }, 90 | versionB: { 91 | percentage: { min: 33, max: 66 } 92 | }, 93 | versionC: { 94 | percentage: { min: 66, max: 100 } 95 | } 96 | }) 97 | 98 | module.exports = rollout 99 | ``` 100 | 101 | ``` js 102 | // A typical Express app demonstrating experiment groups 103 | ... 104 | var rollout = require('./experiment_groups_configuration') 105 | 106 | app.get('/', homepage) 107 | 108 | function homepage(req, res, next) { 109 | rollout.get('homepage_variant', req.current_user.id) 110 | .then(function (version) { 111 | console.assert(/^version(A|B|C)$/.test(version) === true) 112 | res.render('home/' + version) 113 | }) 114 | } 115 | 116 | ``` 117 | 118 | #### Advanced Configuration 119 | 120 | #### `clientFactory` 121 | For clients that require a client factory or function that returns connections, the `clientFactory` can be given a 122 | function that returns a client. 123 | This can be useful when using `ioredis` with Cluster support. 124 | 125 | *Note*: Functions like `multi()` may not work as expected with `ioredis` clusters. 126 | 127 | ``` js 128 | // client_factory_configuration.js 129 | var Redis = require('ioredis') 130 | var rollout = require('node-rollout')({ 131 | clientFactory: function () { 132 | return new Redis.Cluster([{ 133 | port: 6380, 134 | host: '127.0.0.1' 135 | }, { 136 | port: 6381, 137 | host: '127.0.0.1' 138 | }]); 139 | } 140 | }) 141 | ``` 142 | 143 | #### Prefix option 144 | An optional prefix can be passed to the constructor that prepends all keys used by the rollout library. 145 | 146 | ``` js 147 | var client = require('redis').createClient() 148 | var rollout = require('node-rollout')(client, { 149 | prefix: 'my_rollouts' 150 | }) 151 | 152 | ``` 153 | 154 | ### API Options 155 | 156 | #### `rollout.get(key, uid, opt_values)` 157 | 158 | - `key`: `String` The rollout feature key. Eg "new_homepage" 159 | - `uid`: `String` The identifier of which will determine likelyhood of falling in rollout. Typically a user id. 160 | - `opt_values`: `Object` *optional* A lookup object with default percentages and conditions. Defaults to `{id: args.uid}` 161 | - returns `Promise` 162 | 163 | ``` js 164 | rollout.get('button_test', 123) 165 | .then(function () { 166 | render('blue_button') 167 | }) 168 | .catch(function () { 169 | render('red_button') 170 | }) 171 | 172 | rollout.get('another_feature', 123, { 173 | employee: 'user@example.org' 174 | }) 175 | .then(function () { 176 | render('blue_button') 177 | }) 178 | .catch(function () { 179 | render('red_button') 180 | }) 181 | ``` 182 | 183 | #### `rollout.multi(keys)` 184 | The value of this method lets you do a batch redis call (using `redis.multi()`) allowing you to get multiple rollout handler results in one request 185 | 186 | - `keys`: `Array` A list of tuples containing what you would ordinarily pass to `get` 187 | - returns `Promise` 188 | 189 | ``` js 190 | rollout.multi([ 191 | ['onboarding', 123, {}], 192 | ['email_inviter', 123, {}], 193 | ['facebook_chat', 123, { 194 | employees: req.user.email // 'joe@company.com' 195 | }] 196 | ]) 197 | .then(function (results) { 198 | results.forEach(function (r) { 199 | console.log(i.isFulfilled()) // Or 'isRejected()' 200 | }) 201 | }) 202 | 203 | rollout.get('another_feature', 123, { 204 | employee: 'user@example.org' 205 | }) 206 | .then(function () { 207 | render('blue_button') 208 | }) 209 | .catch(function () { 210 | render('red_button') 211 | }) 212 | ``` 213 | 214 | #### `rollout.handler(key, modifiers)` 215 | - `key`: `String` The rollout feature key 216 | - `modifiers`: `Object` 217 | - `modName`: `String` The name of the modifier. Typically `id`, `employee`, `ip`, or any other arbitrary item you would want to modify the rollout 218 | - `percentage`: 219 | - `Number` from `0` - `100`. Can be set to a third decimal place such as `0.001` or `99.999`. Or simply `0` to turn off a feature, or `100` to give a feature to all users 220 | - `Object` containing `min` and `max` keys representing a range of `Number`s between `0` - `100` 221 | - `condition`: `Function` a white-listing method by which you can add users into a group. See examples. 222 | - if `condition` returns a `Promise` (*a thenable object*), then it will use the fulfillment of the `Promise` to resolve or reject the `handler` 223 | - Conditions will only be accepted if they return/resolve with a "truthy" value 224 | 225 | ``` js 226 | rollout.handler('admin_section', { 227 | // 0% of regular users. You may omit `id` since it will default to 0 228 | id: { 229 | percentage: 0 230 | }, 231 | // All users with the company email 232 | employee: { 233 | percentage: 100, 234 | condition: function (val) { 235 | return /@company-email\.com$/.test(val) 236 | } 237 | }, 238 | // special invited people 239 | contractors: { 240 | percentage: 100, 241 | condition: function (user) { 242 | return new Promise(function (resolve, reject) { 243 | redisClient.get('contractors:' + user.id, function (err, is_awesome) { 244 | is_awesome ? resolve() : reject() 245 | }) 246 | }) 247 | } 248 | } 249 | }) 250 | ``` 251 | 252 | #### `rollout.update(key, modifierPercentages)` 253 | - `key`: `String` The rollout feature key 254 | - `modifierPercentages`: `Object` mapping of `modName`:`String` to `percentage` 255 | - `Number` from `0` - `100`. Can be set to a third decimal place such as `0.001` or `99.999`. Or simply `0` to turn off a feature, or `100` to give a feature to all users 256 | - `Object` containing `min` and `max` keys representing a range of `Number`s between `0` - `100` 257 | - returns `Promise` 258 | 259 | ``` js 260 | rollout.update('new_homepage', { 261 | id: 33.333, 262 | employee: 50, 263 | geo_sf: 25 264 | }) 265 | .then(function () { 266 | // values have been updated 267 | }) 268 | ``` 269 | 270 | #### `rollout.modifiers(handlerName)` 271 | - `handlerName`: `String` the rollout feature key 272 | - returns `Promise`: resolves to a modifiers `Object` mapping `modName`: `percentage` 273 | 274 | ``` js 275 | rollout.modifiers('new_homepage') 276 | .then(function (modifiers) { 277 | console.assert(modifiers.employee == 100) 278 | console.assert(modifiers.geo_sf == 50.000) 279 | console.assert(modifiers.id == 33.333) 280 | }) 281 | ``` 282 | 283 | #### `rollout.handlers()` 284 | - return `Promise`: resolves with an array of configured rollout handler names 285 | 286 | ``` js 287 | rollout.handlers() 288 | .then(function (handlers) { 289 | console.assert(handlers[0] === 'new_homepage') 290 | console.assert(handlers[1] === 'other_secret_feature') 291 | }) 292 | ``` 293 | 294 | ### Tests 295 | see [tests/index-test.js](tests/index-test.js) 296 | 297 | ``` sh 298 | make test 299 | ``` 300 | 301 | ### User Interface 302 | Consider using [rollout-ui](https://github.com/ded/rollout-ui) to administrate the values of your rollouts in real-time (as opposed to doing a full deploy). It will make your life much easier and you'll be happy :) 303 | 304 | **Note:** `rollout-ui` does not yet support experiment groups and percentage ranges. 305 | 306 | ### License MIT 307 | 308 | Happy rollout! 309 | -------------------------------------------------------------------------------- /tests/index-test.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | , sinon = require('sinon') 3 | , redis = require('redis').createClient() 4 | , Promise = require('bluebird') 5 | , promised = require('chai-as-promised') 6 | , rollout = require('../') 7 | , expect = chai.expect 8 | 9 | chai.use(promised) 10 | chai.use(require('sinon-chai')) 11 | 12 | Promise.promisifyAll(redis) 13 | 14 | function isCompanyEmail(val) { 15 | return /@company\.com$/.test(val) 16 | } 17 | 18 | describe('rollout', function () { 19 | var subject 20 | 21 | beforeEach(function () { 22 | subject = rollout(redis) 23 | }) 24 | 25 | afterEach(function (done) { 26 | redis.flushdb(done) 27 | }) 28 | 29 | it('fulfills', function () { 30 | return subject.handler('secret_feature', { 31 | employee: { 32 | percentage: 100, 33 | condition: isCompanyEmail 34 | } 35 | }) 36 | .then(function () { 37 | var result = subject.get('secret_feature', 123, { 38 | employee: 'me@company.com' 39 | }) 40 | return expect(result).to.be.fulfilled 41 | }) 42 | }) 43 | 44 | it('fulfills with applicable modifier for percentage', function () { 45 | return subject.handler('secret_feature', { 46 | everyone: { 47 | percentage: 0 48 | }, 49 | employee: { 50 | percentage: 100, 51 | condition: isCompanyEmail 52 | } 53 | }) 54 | .then(function () { 55 | var result = subject.get('secret_feature', 123, { 56 | employee: 'me@company.com' 57 | }) 58 | return expect(result).to.eventually.equal('employee') 59 | }) 60 | }) 61 | 62 | context('percentage range', function () { 63 | beforeEach(function () { 64 | sinon.stub(subject, 'val_to_percent') 65 | return subject.handler('secret_feature', { 66 | groupA: { 67 | percentage: { min: 0, max: 25 } 68 | }, 69 | groupB: { 70 | percentage: { min: 25, max: 50 } 71 | }, 72 | groupC: { 73 | percentage: { min: 50, max: 100 } 74 | } 75 | }) 76 | }) 77 | afterEach(function () { 78 | subject.val_to_percent.restore() 79 | }) 80 | 81 | it('fulfills with applicable modifier for range', function () { 82 | subject.val_to_percent.onCall(0).returns(37) 83 | var result = subject.get('secret_feature', 123) 84 | return expect(result).to.eventually.equal('groupB') 85 | }) 86 | 87 | it('fulfills multiple with applicable modifiers for ranges', function () { 88 | subject.val_to_percent.onCall(0).returns(12) 89 | subject.val_to_percent.onCall(1).returns(49.97) 90 | subject.val_to_percent.onCall(2).returns(72) 91 | return subject.multi([ 92 | ['secret_feature', 123], 93 | ['secret_feature', 321], 94 | ['secret_feature', 213] 95 | ]) 96 | .then(function(results) { 97 | expect(results[0].isFulfilled()).to.be.true 98 | expect(results[0].value()).to.equal('groupA') 99 | expect(results[1].isFulfilled()).to.be.true 100 | expect(results[1].value()).to.equal('groupB') 101 | expect(results[2].isFulfilled()).to.be.true 102 | expect(results[2].value()).to.equal('groupC') 103 | }) 104 | }) 105 | }) 106 | 107 | it('fulfills when condition returns a resolved promise', function () { 108 | return subject.handler('promise_secret_feature', { 109 | beta_testa: { 110 | percentage: 100, 111 | condition: function () { 112 | return new Promise(function (resolve) { 113 | setTimeout(resolve.bind(null, true), 10) 114 | }) 115 | } 116 | } 117 | }) 118 | .then(function () { 119 | var result = subject.get('promise_secret_feature', 123, { 120 | beta_testa: 'foo' 121 | }) 122 | return expect(result).to.be.fulfilled 123 | }) 124 | }) 125 | 126 | it('rejects when condition returns a rejected promise', function () { 127 | return subject.handler('promise_secret_feature', { 128 | beta_testa: { 129 | percentage: 100, 130 | condition: function () { 131 | return Promise.reject() 132 | } 133 | } 134 | }) 135 | .then(function () { 136 | var result = subject.get('promise_secret_feature', 123, { 137 | beta_testa: 'foo' 138 | }) 139 | return expect(result).to.be.rejected 140 | }) 141 | }) 142 | 143 | it('fulfills if `any` condition passes', function () { 144 | return subject.handler('mixed_secret_feature', { 145 | beta_testa: { 146 | percentage: 100, 147 | condition: function () { 148 | return Promise.resolve(true) 149 | } 150 | }, 151 | beta_testa1: { 152 | percentage: 0, 153 | condition: function () { 154 | return Promise.resolve(true) 155 | } 156 | }, 157 | beta_testa2: { 158 | percentage: 100, 159 | condition: function () { 160 | return Promise.reject() 161 | } 162 | }, 163 | beta_testa3: { 164 | percentage: 100, 165 | condition: function () { 166 | return false 167 | } 168 | } 169 | }) 170 | .then(function () { 171 | var result = subject.get('mixed_secret_feature', 123, { 172 | beta_testa: 'foo', 173 | beta_testa1: 'foo', 174 | beta_testa2: 'foo', 175 | beta_testa3: 'foo' 176 | }) 177 | return expect(result).to.be.fulfilled 178 | }) 179 | }) 180 | 181 | it('rejects if all conditions fail', function () { 182 | return subject.handler('mixed_secret_feature', { 183 | beta_testa1: { 184 | percentage: 0, 185 | condition: function () { 186 | return Promise.resolve(true) 187 | } 188 | }, 189 | beta_testa2: { 190 | percentage: 100, 191 | condition: function () { 192 | return Promise.reject() 193 | } 194 | }, 195 | beta_testa3: { 196 | percentage: 100, 197 | condition: function () { 198 | return false 199 | } 200 | } 201 | }) 202 | .then(function () { 203 | var result = subject.get('mixed_secret_feature', 123, { 204 | beta_testa1: 'foo', 205 | beta_testa2: 'foo', 206 | beta_testa3: 'foo' 207 | }) 208 | return expect(result).to.be.rejected 209 | }) 210 | }) 211 | 212 | it('can retrieve percentage mod values', function () { 213 | return subject.handler('super_secret', { 214 | foo: { 215 | percentage: 12 216 | }, 217 | bar: { 218 | percentage: 34 219 | } 220 | }) 221 | .then(function () { 222 | var result = subject.modifiers('super_secret') 223 | return expect(result).to.eventually.deep.equal({ 224 | foo: 12, 225 | bar: 34 226 | }) 227 | }) 228 | }) 229 | 230 | it('can retrieve range mod values', function () { 231 | return subject.handler('super_secret', { 232 | foo: { 233 | percentage: { min: 0, max: 50 } 234 | }, 235 | bar: { 236 | percentage: { min: 50, max: 100 } 237 | } 238 | }) 239 | .then(function () { 240 | var result = subject.modifiers('super_secret') 241 | return expect(result).to.eventually.deep.equal({ 242 | foo: { min: 0, max: 50 }, 243 | bar: { min: 50, max: 100 } 244 | }) 245 | }) 246 | }) 247 | 248 | it('can retrieve all handler names', function () { 249 | var o = { 250 | foo: { 251 | percentage: 100 252 | } 253 | } 254 | return Promise.all([ 255 | subject.handler('youza', o), 256 | subject.handler('huzzah', o) 257 | ]) 258 | .then(function () { 259 | var result = subject.handlers() 260 | return expect(result).to.eventually.deep.equal(['youza', 'huzzah']) 261 | }) 262 | }) 263 | 264 | it('gets multiple keys', function () { 265 | return subject.handler('secret_feature', { 266 | employee: { 267 | percentage: 100, 268 | condition: isCompanyEmail 269 | } 270 | }) 271 | .then(function () { 272 | return subject.multi([ 273 | ['secret_feature', 123, { employee: 'me@company.com' }], 274 | ['secret_feature', 321, { employee: 'you@company.com' }], 275 | ['secret_feature', 231, { employee: 'someone@else.com' }] 276 | ]) 277 | .then(function (result) { 278 | expect(result[0].isFulfilled()).to.be.true 279 | expect(result[0].value()).to.equal('employee') 280 | expect(result[1].isFulfilled()).to.be.true 281 | expect(result[1].value()).to.equal('employee') 282 | expect(result[2].isRejected()).to.be.true 283 | }) 284 | }) 285 | }) 286 | 287 | context('not allowed percentage', function () { 288 | beforeEach(function () { 289 | sinon.stub(subject, 'val_to_percent') 290 | }) 291 | afterEach(function () { 292 | subject.val_to_percent.restore() 293 | }) 294 | 295 | it('rejects if not in allowed percentage', function () { 296 | subject.val_to_percent.returns(51.001) 297 | return subject.handler('another_feature', { 298 | id: { 299 | percentage: 51.000 300 | } 301 | }) 302 | .then(function () { 303 | var result = subject.get('another_feature', 123) 304 | return expect(result).to.be.rejected 305 | }) 306 | }) 307 | 308 | it('should be able to update a key with a percentage', function () { 309 | subject.val_to_percent.returns(50) 310 | return subject.handler('button_test', { 311 | id: { 312 | percentage: 100 313 | } 314 | }) 315 | .then(function() { 316 | var result = subject.get('button_test', 123) 317 | return expect(result).to.be.fulfilled 318 | }) 319 | .then(function () { 320 | return subject.update('button_test', { 321 | id: 49 322 | }) 323 | .then(function () { 324 | var result = subject.get('button_test', 123) 325 | return expect(result).to.be.rejected 326 | }) 327 | }) 328 | }) 329 | 330 | it('should be able to update a key with a range', function () { 331 | subject.val_to_percent.returns(50) 332 | return subject.handler('experiment', { 333 | groupA: { 334 | percentage: 100 335 | }, 336 | groupB: { 337 | percentage: 0 338 | } 339 | }) 340 | .then(function() { 341 | var result = subject.get('experiment', 123) 342 | return expect(result).to.eventually.equal('groupA') 343 | }) 344 | .then(function () { 345 | return subject.update('experiment', { 346 | groupA: { min: 0, max: 49 }, 347 | groupB: { min: 49, max: 100 } 348 | }) 349 | .then(function () { 350 | var result = subject.get('experiment', 123) 351 | return expect(result).to.eventually.equal('groupB') 352 | }) 353 | }) 354 | }) 355 | 356 | it('is optimistic', function () { 357 | subject.val_to_percent.returns(49) 358 | return subject.handler('super_secret', { 359 | none: { 360 | percentage: 0 361 | }, 362 | id: { 363 | // give feature to 49% of users 364 | percentage: 50 365 | }, 366 | employee: { 367 | // give to 51% of employees 368 | percentage: 51, 369 | condition: isCompanyEmail 370 | } 371 | }) 372 | .then(function () { 373 | var result = subject.get('super_secret', 123, { 374 | employee: 'regular@gmail.com' 375 | }) 376 | // is rejected by company email, but falls within allowed regular users 377 | return expect(result).to.eventually.equal('id') 378 | }) 379 | }) 380 | }) 381 | 382 | context('with a prefix option', function () { 383 | beforeEach(function () { 384 | subject = rollout(redis, { prefix: 'TEST_PREFIX' }) 385 | }) 386 | 387 | it('fulfills', function () { 388 | return subject.handler('secret_feature', { 389 | employee: { 390 | percentage: 100, 391 | condition: isCompanyEmail 392 | } 393 | }) 394 | .then(function () { 395 | return redis.keysAsync('TEST_PREFIX:*') 396 | }) 397 | .then(function (keys) { 398 | expect(keys).to.have.length(1) 399 | var result = subject.get('secret_feature', 123, { 400 | employee: 'me@company.com' 401 | }) 402 | return expect(result).to.be.fulfilled 403 | }) 404 | }) 405 | }) 406 | 407 | context('with a client factory', function () { 408 | beforeEach(function () { 409 | subject = rollout({ 410 | clientFactory: function () { 411 | return redis 412 | } 413 | }) 414 | }) 415 | 416 | it('fulfills', function () { 417 | return subject.handler('secret_feature', { 418 | employee: { 419 | percentage: 100, 420 | condition: isCompanyEmail 421 | } 422 | }) 423 | .then(function () { 424 | var result = subject.get('secret_feature', 123, { 425 | employee: 'me@company.com' 426 | }) 427 | return expect(result).to.be.fulfilled 428 | }) 429 | }) 430 | }) 431 | }) 432 | --------------------------------------------------------------------------------