eeny-meeny 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e28ef5a3f25b3acf3e554f578623aab014bc8033
4
+ data.tar.gz: 017bf53a5cca472993e25c1d09cc514e3a0983b9
5
+ SHA512:
6
+ metadata.gz: e188c4dd1ceeee62fdd3efc5ab445808d49b34cb75579dbe591c381c6fab73c2206261473ad27d7dd8df3e72ca4d5d86508ef5d97ad8aba2c2661e2dee926b58
7
+ data.tar.gz: e7f33467b3c326a158405a528e4ca787afc05061fca5e0fa4581a083f00f7523bc9ebbb6bb1e0d36e1274e41d09e712fef0685bc527c4630de6b89bb1d970437
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ ### 1.0.0 (2016-07-03)
2
+
3
+ Features:
4
+
5
+ - Initial version of split testing tool. Includes:
6
+ - Experiment helpers for Rails
7
+ - Run any number of experiments at the same time.
8
+ - Have any number of variations in each experiment.
9
+ - Set the weight (likelyhood of participants excountering) each experiment variation customly.
10
+ - Encrypt experiment cookies.
11
+ - Route constraint for full page split tests.
data/Gemfile ADDED
@@ -0,0 +1,7 @@
1
+ source 'https://rubygems.org'
2
+
3
+ group :test do
4
+ gem 'codeclimate-test-reporter', require: nil
5
+ end
6
+
7
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Christian Orthmann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,86 @@
1
+ eeny-meeny
2
+ ==========================
3
+ [![Code Climate](https://codeclimate.com/github/corthmann/eeny-meeny/badges/gpa.svg)](https://codeclimate.com/github/corthmann/eeny-meeny)
4
+ [![Test Coverage](https://codeclimate.com/github/corthmann/eeny-meeny/badges/coverage.svg)](https://codeclimate.com/github/corthmann/eeny-meeny/coverage)
5
+
6
+ Installation
7
+ -------------
8
+ You can install this gem by using the following command:
9
+ ```
10
+ gem install eeny-meeny
11
+ ```
12
+ or by adding the the following line to your Gemfile.
13
+ ```
14
+ gem 'eeny-meeny'
15
+ ```
16
+
17
+ Configuration
18
+ -------------
19
+ `eeny-meeny` should be configured in your Rails environment file. Preferably loaded through `secrets.yml`
20
+
21
+ The following configurations are available:
22
+
23
+ * `config.eeny_meeny.cookies.path` Defaults to `'/'`. Sets the `path` cookie attribute. If this configuartion is set to `nil` it means that each page will get its own cookie.
24
+ * `config.eeny_meeny.cookies.same_site` Defaults to `:strict`. Accepts: `:strict`, `:lax` and `nil`. Sets the `SameSite` cookie attribute. Selecting `nil` will disable the header on the cookie.
25
+ * `config.eeny_meeny.secure` Boolean value. Defaults to `true` and determines if experiment cookies should be encrypted or not.
26
+ * `config.eeny_meeny.secret` sets the secret used for encrypting experiment cookies.
27
+ * `config.eeny_meeny.experiments` list of experiment-data. It is easiest to load this from a `.yml` file with the following structure:
28
+
29
+ ```
30
+ :experiment_1:
31
+ :name: Awesome Experiment
32
+ :version: 1
33
+ :variations:
34
+ :a:
35
+ :name: Variation A
36
+ :weight: 0.8
37
+ :options:
38
+ :message: A rocks, B sucks
39
+ :b:
40
+ :name: Variation B
41
+ :weight: 0.2
42
+ :options:
43
+ :message: B is an all-star!
44
+ ```
45
+
46
+ Usage
47
+ -------------
48
+ `eeny-meeny` adds the following helpers to your controllers and views:
49
+
50
+ * `participates_in?(experiement_id, variation_id: nil)` Returns the chosen variation for the current user if he participates in the experiment.
51
+
52
+ Full page split tests
53
+ -------------
54
+ If you want to completely redesign a page but test it in production as a split test against your old page, using identical routes, then it can be achieved as follows:
55
+
56
+ 1. Create an experiment like this:
57
+
58
+ ```
59
+ :example_page:
60
+ :name: Test V1 vs. V2
61
+ :v1:
62
+ :name: First version of the page
63
+ :weight: 0.9
64
+ :v2:
65
+ :name: Second version of the page
66
+ :weight: 0.1
67
+ ```
68
+
69
+ 2. Namespace your controller and views (ex. `ExamplesController` becommes `V1::ExamplesController` )
70
+ 3. Copy the route(s) for `ExamplesController` and use `controller: 'v1/examples`
71
+ 4. Add `require 'eeny-meeny/route_constraint` to `routes.rb`
72
+ 4. Surround your `v1/examples` route(s) with the following constraint:
73
+
74
+ ```
75
+ constraints(EenyMeeny::RouteConstraint.new(:example_page, variation_id: :v1) do
76
+ # your v1 routes goes here.
77
+ end
78
+ ```
79
+
80
+ Now 90% of the users will experience V1 and 10% will experience V2.
81
+
82
+ Special thanks
83
+ -------------
84
+ As part of building this gem I borrowed the `Encryptor` class from the `encrypted_cookie` gem (https://github.com/cvonkleist/encrypted_cookie)
85
+
86
+ All credits for the cookie encryption goes to that project.
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+ task :test => :spec
@@ -0,0 +1,23 @@
1
+ require File.expand_path('../lib/eeny-meeny/version', __FILE__)
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 'eeny-meeny'
5
+ s.version = EenyMeeny::VERSION.dup
6
+ s.date = '2016-07-03'
7
+ s.summary = "A simple split testing tool for Rails"
8
+ s.authors = ["Christian Orthmann"]
9
+ s.email = 'christian.orthmann@gmail.com'
10
+ s.require_path = 'lib'
11
+ s.files = `git ls-files`.split("\n") - %w(.rvmrc .gitignore)
12
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - %w(.rvmrc .gitignore)
13
+ s.homepage = 'http://rubygems.org/gems/eeny-meeny'
14
+ s.license = 'MIT'
15
+
16
+ s.add_development_dependency('rake', '~> 10')
17
+ s.add_development_dependency('rspec', '~> 3')
18
+ s.add_development_dependency('simplecov', '~> 0')
19
+ s.add_development_dependency('simplecov-rcov', '~> 0')
20
+ s.add_development_dependency('yard', '~> 0')
21
+ s.add_runtime_dependency('rack', '>= 1.2.1', '< 2')
22
+ s.add_runtime_dependency('activesupport', '>= 3.0.0', '< 5.0.0')
23
+ end
data/lib/eeny-meeny.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'eeny-meeny/version'
2
+ require 'eeny-meeny/railtie' if defined?(Rails)
3
+
4
+ module EenyMeeny
5
+ EENY_MEENY_COOKIE_PREFIX = 'eeny_meeny_'.freeze
6
+ end
@@ -0,0 +1,120 @@
1
+ require 'openssl'
2
+ require 'rack/utils'
3
+
4
+ module EenyMeeny
5
+ # Encrypts messages with authentication
6
+ #
7
+ # The use of authentication is essential to avoid Chosen Ciphertext
8
+ # Attacks. By using this in an encrypt then MAC form, we avoid some
9
+ # attacks such as e.g. being used as a CBC padding oracle to decrypt
10
+ # the ciphertext.
11
+ class Encryptor
12
+ # Create the encryptor
13
+ #
14
+ # Pass in the secret, which should be at least 32-bytes worth of
15
+ # entropy, e.g. a string generated by `SecureRandom.hex(32)`.
16
+ # This also allows specification of the algorithm for the cipher
17
+ # and MAC. But don't change that unless you're very sure.
18
+ def initialize(secret, cipher = 'aes-256-cbc', hmac = 'SHA256')
19
+ @cipher = cipher
20
+ @hmac = hmac
21
+
22
+ # use the HMAC to derive two independent keys for the encryption and
23
+ # authentication of ciphertexts It is bad practice to use the same key
24
+ # for encryption and authentication. This also allows us to use all
25
+ # of the entropy in a long key (e.g. 64 hex bytes) when straight
26
+ # assignement would could result in assigning a key with a much
27
+ # reduced key space. Also, the personalisation strings further help
28
+ # reduce the possibility of key reuse by ensuring it should be unique
29
+ # to this gem, even with shared secrets.
30
+ @encryption_key = hmac("EncryptedCookie Encryption", secret)
31
+ @authentication_key = hmac("EncryptedCookie Authentication", secret)
32
+ end
33
+
34
+ # Encrypts message
35
+ #
36
+ # Returns the base64 encoded ciphertext plus IV. In addtion, the
37
+ # message is prepended with a MAC code to prevent chosen ciphertext
38
+ # attacks.
39
+ def encrypt(message)
40
+ # encrypt the message
41
+ encrypted = encrypt_message(message)
42
+
43
+ [authenticate_message(encrypted) + encrypted].pack('m0')
44
+ end
45
+
46
+ # decrypts base64 encoded ciphertext
47
+ #
48
+ # First, it checks the message tag and returns nil if that fails to verify.
49
+ # Otherwise, the data is passed on to the AES function for decryption.
50
+ def decrypt(ciphertext)
51
+ ciphertext = ciphertext.unpack('m').first
52
+ tag = ciphertext[0, hmac_length]
53
+ ciphertext = ciphertext[hmac_length..-1]
54
+
55
+ # make sure we actually had enough data for the tag too.
56
+ if tag && ciphertext && verify_message(tag, ciphertext)
57
+ decrypt_ciphertext(ciphertext)
58
+ else
59
+ nil
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # HMAC digest of the message using the given secret
66
+ def hmac(secret, message)
67
+ OpenSSL::HMAC.digest(@hmac, secret, message)
68
+ end
69
+
70
+ def hmac_length
71
+ OpenSSL::Digest.new(@hmac).size
72
+ end
73
+
74
+ # returns the message authentication tag
75
+ #
76
+ # This is computed as HMAC(authentication_key, message)
77
+ def authenticate_message(message)
78
+ hmac(@authentication_key, message)
79
+ end
80
+
81
+ # verifies the message
82
+ #
83
+ # This does its best to be constant time, by use of the rack secure compare
84
+ # function.
85
+ def verify_message(tag, message)
86
+ own_tag = authenticate_message(message)
87
+ Rack::Utils.secure_compare(tag, own_tag)
88
+ end
89
+
90
+ # Encrypt
91
+ #
92
+ # Encrypts the given message with a random IV, then returns the ciphertext
93
+ # with the IV prepended.
94
+ def encrypt_message(message)
95
+ aes = OpenSSL::Cipher::Cipher.new(@cipher).encrypt
96
+ aes.key = @encryption_key
97
+ iv = aes.random_iv
98
+ aes.iv = iv
99
+ iv + (aes.update(message) << aes.final)
100
+ end
101
+
102
+ # Decrypt
103
+ #
104
+ # Pulls the IV off the front of the message and decrypts. Catches
105
+ # OpenSSL errors and returns nil. But this should never happen, as the
106
+ # verify method should catch all corrupted ciphertexts.
107
+ def decrypt_ciphertext(ciphertext)
108
+ aes = OpenSSL::Cipher::Cipher.new(@cipher).decrypt
109
+ aes.key = @encryption_key
110
+ iv = ciphertext[0, aes.iv_len]
111
+ aes.iv = iv
112
+ crypted_text = ciphertext[aes.iv_len..-1]
113
+ return nil if crypted_text.nil? || iv.nil?
114
+ aes.update(crypted_text) << aes.final
115
+ rescue
116
+ nil
117
+ end
118
+
119
+ end
120
+ end
@@ -0,0 +1,30 @@
1
+ require 'eeny-meeny/variation'
2
+ require 'active_support/time'
3
+ require 'active_support/core_ext/enumerable'
4
+
5
+ module EenyMeeny
6
+ class Experiment
7
+ attr_reader :id, :name, :version, :variations, :total_weight, :end_at, :start_at
8
+
9
+ def initialize(id, name: '', version: 1, variations: {}, start_at: nil, end_at: nil)
10
+ @id = id
11
+ @name = name
12
+ @version = version
13
+ @variations = variations.map do |variation_id, variation|
14
+ Variation.new(variation_id, **variation)
15
+ end
16
+ @total_weight = (@variations.empty? ? 1.0 : @variations.sum { |variation| variation.weight.to_f })
17
+
18
+ @start_at = Time.zone.parse(start_at) if start_at
19
+ @end_at = Time.zone.parse(end_at) if end_at
20
+ end
21
+
22
+ def pick_variation
23
+ Hash[
24
+ @variations.map do |variation|
25
+ [variation, variation.weight]
26
+ end
27
+ ].max_by { |_, weight| rand ** (@total_weight / weight) }.first
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,21 @@
1
+ require 'eeny-meeny/shared_methods'
2
+
3
+ module EenyMeeny::ExperimentHelper
4
+ @@eeny_meeny_encryptor = nil
5
+
6
+ def participates_in?(experiment_id, variation_id: nil)
7
+ cookie = eeny_meeny_cookie(experiment_id)
8
+ cookie[:variation] unless cookie.nil? || (variation_id.present? && variation_id != cookie[:variation].id)
9
+ end
10
+
11
+ private
12
+
13
+ def eeny_meeny_cookie(experiment_id)
14
+ cookie = cookies[EenyMeeny::EENY_MEENY_COOKIE_PREFIX+experiment_id.to_s+'_v'+experiment_version(experiment_id).to_s]
15
+ if cookie
16
+ Marshal.load(decrypt(cookie)) rescue nil
17
+ end
18
+ end
19
+
20
+ include EenyMeeny::SharedMethods
21
+ end
@@ -0,0 +1,64 @@
1
+ require 'rack'
2
+ require 'time'
3
+ require 'active_support/time'
4
+ require 'eeny-meeny/middleware_helper'
5
+ require 'eeny-meeny/experiment'
6
+ require 'eeny-meeny/encryptor'
7
+
8
+ module EenyMeeny
9
+ class Middleware
10
+ include EenyMeeny::MiddlewareHelper
11
+
12
+ def initialize(app, experiments, secure, secret, cookie_path, cookie_same_site)
13
+ @app = app
14
+ @experiments = experiments.map do |id, experiment|
15
+ EenyMeeny::Experiment.new(id, **experiment)
16
+ end
17
+ @secure = secure
18
+ @cookie_config = { path: cookie_path, same_site: cookie_same_site }
19
+ @encryptor = EenyMeeny::Encryptor.new(secret) if secure
20
+ end
21
+
22
+ def call(env)
23
+ request = Rack::Request.new(env)
24
+ cookies = request.cookies
25
+ now = Time.zone.now
26
+ new_cookies = {}
27
+ existing_set_cookie_header = env['Set-Cookie']
28
+ # Prepare for experiments.
29
+ @experiments.each do |experiment|
30
+ # Skip experiments that haven't started yet or if it ended
31
+ next if experiment.start_at && (now < experiment.start_at)
32
+ next if experiment.end_at && (now > experiment.end_at)
33
+ # skip experiments that already have a cookie
34
+ unless has_experiment_cookie?(cookies, experiment)
35
+ env['Set-Cookie'] = ''
36
+ cookie_value = generate_cookie_value(experiment, @cookie_config)
37
+ cookie_value[:value] = @encryptor.encrypt(cookie_value[:value]) if @secure
38
+ # Set HTTP_COOKIE header to enable experiment on first pageview
39
+ Rack::Utils.set_cookie_header!(env,
40
+ experiment_cookie_name(experiment),
41
+ cookie_value)
42
+ env['HTTP_COOKIE'] = '' if env['HTTP_COOKIE'].nil?
43
+ env['HTTP_COOKIE'] += '; ' unless env['HTTP_COOKIE'].empty?
44
+ env['HTTP_COOKIE'] += env['Set-Cookie']
45
+ new_cookies[experiment_cookie_name(experiment)] = cookie_value
46
+ end
47
+ end
48
+ # Clean up 'Set-Cookie' header.
49
+ if existing_set_cookie_header.nil?
50
+ env.delete('Set-Cookie')
51
+ else
52
+ env['Set-Cookie'] = existing_set_cookie_header
53
+ end
54
+ # Delegate to app
55
+ status, headers, body = @app.call(env)
56
+ response = Rack::Response.new(body, status, headers)
57
+ # Add new cookies to 'Set-Cookie' header
58
+ new_cookies.each do |key, value|
59
+ response.set_cookie(key,value)
60
+ end
61
+ response.finish
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,25 @@
1
+ module EenyMeeny::MiddlewareHelper
2
+ def has_experiment_cookie?(cookies, experiment)
3
+ cookies.has_key?(experiment_cookie_name(experiment))
4
+ end
5
+
6
+ def generate_cookie_value(experiment, cookie_config)
7
+ variation = experiment.pick_variation
8
+ cookie = {
9
+ expires: (experiment.end_at || 1.year.from_now),
10
+ httponly: true,
11
+ value: Marshal.dump({
12
+ name: experiment.name,
13
+ variation: variation,
14
+ })
15
+ }
16
+ cookie[:same_site] = cookie_config[:same_site] unless cookie_config[:same_site].nil?
17
+ cookie[:path] = cookie_config[:path] unless cookie_config[:path].nil?
18
+ cookie
19
+ end
20
+
21
+ private
22
+ def experiment_cookie_name(experiment)
23
+ EenyMeeny::EENY_MEENY_COOKIE_PREFIX+experiment.id.to_s+'_v'+experiment.version.to_s
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ require 'eeny-meeny/experiment_helper'
2
+ require 'eeny-meeny/middleware'
3
+
4
+ module EenyMeeny
5
+ class Railtie < Rails::Railtie
6
+ config.eeny_meeny = ActiveSupport::OrderedOptions.new
7
+ # default config values. these can be changed in the rails environment configuration.
8
+ config.eeny_meeny.experiments = []
9
+ config.eeny_meeny.secure = true
10
+ config.eeny_meeny.secret = '9fc8b966eca7d03d55df40c01c10b8e02bf1f9d12d27b8968d53eb53e8c239902d00bf6afae5e726ce1111159eeb2f8f0e77233405db1d82dd71397f651a0a4f'
11
+ config.eeny_meeny.cookies = ActiveSupport::OrderedOptions.new
12
+ config.eeny_meeny.cookies.path = '/'
13
+ config.eeny_meeny.cookies.same_site = :strict
14
+
15
+ initializer 'eeny_meeny.initialize' do |app|
16
+ ActionController::Base.send :include, EenyMeeny::ExperimentHelper
17
+ ActionView::Base.send :include, EenyMeeny::ExperimentHelper
18
+
19
+ app.middleware.insert_before 'ActionDispatch::Cookies', EenyMeeny::Middleware,
20
+ config.eeny_meeny.experiments,
21
+ config.eeny_meeny.secure,
22
+ config.eeny_meeny.secret,
23
+ config.eeny_meeny.cookies.path,
24
+ config.eeny_meeny.cookies.same_site
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ require 'eeny-meeny/shared_methods'
2
+
3
+ module EenyMeeny
4
+ class RouteConstraint
5
+ @@eeny_meeny_encryptor = nil
6
+
7
+ def initialize(experiment_id, variation_id: nil)
8
+ @experiment_id = experiment_id
9
+ @variation_id = variation_id
10
+ @version = experiment_version(experiment_id)
11
+ end
12
+
13
+ def matches?(request)
14
+ !participates_in?(request).nil?
15
+ end
16
+
17
+ private
18
+
19
+ def participates_in?(request)
20
+ cookie = eeny_meeny_cookie(request)
21
+ cookie[:variation] unless cookie.nil? || (!cookie.nil? && @variation_id.present? && @variation_id != cookie[:variation].id)
22
+ end
23
+
24
+ def eeny_meeny_cookie(request)
25
+ cookie = request.cookie_jar[EenyMeeny::EENY_MEENY_COOKIE_PREFIX+@experiment_id.to_s+'_v'+@version.to_s]
26
+ if cookie
27
+ Marshal.load(decrypt(cookie)) rescue nil
28
+ end
29
+ end
30
+
31
+ include EenyMeeny::SharedMethods
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ module EenyMeeny::SharedMethods
2
+
3
+ private
4
+
5
+ def experiment_version(experiment_id)
6
+ (Rails.application.config.eeny_meeny.experiments.
7
+ try(:[], experiment_id.to_sym).try(:[], :version) || 1) rescue 1
8
+ end
9
+
10
+ def decrypt(cookie)
11
+ begin
12
+ if Rails.application.config.eeny_meeny.secure
13
+ # Memoize encryptor to avoid creating new instances on every request.
14
+ @@eeny_meeny_encryptor ||= EenyMeeny::Encryptor.new(Rails.application.config.eeny_meeny.secret)
15
+ @@eeny_meeny_encryptor.decrypt(cookie)
16
+ else
17
+ cookie
18
+ end
19
+ rescue
20
+ nil
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ module EenyMeeny
2
+ class Variation
3
+ attr_reader :id, :name, :weight, :options
4
+
5
+ def initialize(id, name: '', weight: 1, **options)
6
+ @id = id
7
+ @name = name
8
+ @weight = weight
9
+ @options = options
10
+ end
11
+
12
+ def marshal_dump
13
+ [@id, { name: @name, weight: @weight, **@options }]
14
+ end
15
+
16
+ def marshal_load(array)
17
+ send :initialize, array[0], **array[1]
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module EenyMeeny
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,62 @@
1
+ require 'spec_helper'
2
+ require 'eeny-meeny/experiment'
3
+ require 'eeny-meeny/variation'
4
+
5
+ describe EenyMeeny::Experiment do
6
+ describe 'when initialized' do
7
+
8
+ context 'with weighted variations' do
9
+ subject do
10
+ described_class.new(:experiment_1,
11
+ name: 'Test 1',
12
+ variations: {
13
+ a: { name: 'A', weight: 0.5 },
14
+ b: { name: 'B', weight: 0.3 }})
15
+ end
16
+
17
+ it 'sets the instance variables' do
18
+ expect(subject.id).to eq(:experiment_1)
19
+ expect(subject.name).to eq('Test 1')
20
+ expect(subject.variations).to be_a Array
21
+ expect(subject.variations.size).to eq(2)
22
+ end
23
+
24
+ it "has a 'total_weight' equal to the sum of the variation weights" do
25
+ expect(subject.total_weight).to eq(0.8)
26
+ end
27
+
28
+ describe '#pick_variation' do
29
+ it 'picks a variation' do
30
+ expect(subject.pick_variation).to be_a EenyMeeny::Variation
31
+ end
32
+ end
33
+ end
34
+
35
+ context 'with non-weighted variations' do
36
+ subject do
37
+ described_class.new(:experiment_1,
38
+ name: 'Test 1',
39
+ variations: {
40
+ a: { name: 'A' },
41
+ b: { name: 'B' }})
42
+ end
43
+
44
+ it 'sets the instance variables' do
45
+ expect(subject.id).to eq(:experiment_1)
46
+ expect(subject.name).to eq('Test 1')
47
+ expect(subject.variations).to be_a Array
48
+ expect(subject.variations.size).to eq(2)
49
+ end
50
+
51
+ it "has a 'total_weight' equal to the number of the variation weights" do
52
+ expect(subject.total_weight).to eq(2)
53
+ end
54
+
55
+ describe '#pick_variation' do
56
+ it 'picks a variation' do
57
+ expect(subject.pick_variation).to be_a EenyMeeny::Variation
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,77 @@
1
+ require 'spec_helper'
2
+ require 'eeny-meeny/encryptor'
3
+ require 'eeny-meeny/middleware'
4
+
5
+ def initialize_app(secure: true, secret: 'test', path: '/', same_site: :strict)
6
+ experiments = YAML.load_file(File.join('spec','fixtures','experiments.yml'))
7
+ described_class.new(app, experiments, secure, secret, path, same_site)
8
+ end
9
+
10
+ describe EenyMeeny::Middleware do
11
+
12
+ let(:app) { MockRackApp.new }
13
+ before(:example) do
14
+ allow(Time).to receive_message_chain(:zone, :now) { Time.now }
15
+ end
16
+
17
+ describe 'when initialized' do
18
+
19
+ context "with 'config.eeny_meeny.secure = true'" do
20
+ it 'creates an instance of EenyMeeny::Encryptor' do
21
+ instance = initialize_app
22
+ expect(instance.instance_variable_get(:@secure)).to be true
23
+ expect(instance.instance_variable_get(:@encryptor)).to be_a EenyMeeny::Encryptor
24
+ end
25
+ end
26
+
27
+ context "with 'config.eeny_meeny.secure = false'" do
28
+ it 'does not have an instance of EenyMeeny::Encryptor' do
29
+ instance = initialize_app(secure: false)
30
+ expect(instance.instance_variable_get(:@secure)).to be false
31
+ expect(instance.instance_variable_get(:@encryptor)).to be nil
32
+ end
33
+ end
34
+ end
35
+
36
+ describe 'when called with a GET request' do
37
+ subject { initialize_app }
38
+
39
+ context "and the request doesn't contain the experiment cookie" do
40
+ let(:request) { Rack::MockRequest.new(subject) }
41
+
42
+ before(:example) do
43
+ @response = request.get('/test', 'CONTENT_TYPE' => 'text/plain')
44
+ end
45
+
46
+ it "sets the 'HTTP_COOKIE' header on the request" do
47
+ expect(app['HTTP_COOKIE']).to be
48
+ expect(app['HTTP_COOKIE'].length).to be > 0
49
+ end
50
+
51
+ it "sets the 'Set-Cookie' header on the response" do
52
+ expect(@response['Set-Cookie']).to be
53
+ expect(@response['Set-Cookie'].length).to be > 0
54
+ end
55
+ end
56
+
57
+ context 'and the request already contains the experiment cookie' do
58
+ let(:request) { Rack::MockRequest.new(subject) }
59
+
60
+ before(:example) do
61
+ @original_request_cookies = 'test=abc;eeny_meeny_my_page_v1=on1tOQ5hiKdA0biVZVwvTUQcmkODacwdpi%2FedQJIYQz9KdWYAXqzCafF5Dqqa6xtHFBdXYVmz%2Bp4%2FigmKz4hBVYZbJU%2FMwBbvYG%2BIoBelk10PxwtyxbA%2BiDzFT4jZeiTkNOmZ3rp1Gzz74JjT4aocqB187p7SrpeM2jfyZ8ZKPOiZs6tXf0QoXkV%2BZbtxJLRPr5lgmGxslfM8vCIm1%2F0HQ%3D%3D;'
62
+ @response = request.get('/test',
63
+ 'CONTENT_TYPE' => 'text/plain',
64
+ 'HTTP_COOKIE' => @original_request_cookies)
65
+ end
66
+
67
+ it "does not change the 'HTTP_COOKIE' header on the request" do
68
+ expect(app['HTTP_COOKIE']).to eq(@original_request_cookies)
69
+ end
70
+
71
+ it "does not set the 'Set-Cookie' header on the response" do
72
+ expect(@response['Set-Cookie']).to be nil
73
+ end
74
+ end
75
+ end
76
+
77
+ end
@@ -0,0 +1,49 @@
1
+ require 'spec_helper'
2
+ require 'eeny-meeny/variation'
3
+
4
+ describe EenyMeeny::Variation do
5
+ describe 'when initialized' do
6
+
7
+ subject do
8
+ described_class.new(:a,
9
+ name: 'A',
10
+ weight: 0.5,
11
+ custom_option_1: 'asd1',
12
+ custom_option_2: 'asd2')
13
+ end
14
+
15
+ it "sets the 'id'" do
16
+ expect(subject.id).to eq(:a)
17
+ end
18
+
19
+ it "sets the 'name'" do
20
+ expect(subject.name).to eq('A')
21
+ end
22
+
23
+ it "sets the 'weight'" do
24
+ expect(subject.weight).to eq(0.5)
25
+ end
26
+
27
+ it "sets the custom 'options'" do
28
+ expect(subject.options).to be_a Hash
29
+ expect(subject.options[:custom_option_1]).to eq('asd1')
30
+ expect(subject.options[:custom_option_2]).to eq('asd2')
31
+ end
32
+
33
+ describe '#marshal_dump' do
34
+ it 'can load a marshal dump correctly' do
35
+ dump = Marshal.dump(subject)
36
+ expect(dump).to be_a String
37
+ loaded_object = Marshal.load(dump)
38
+ expect(loaded_object).to_not be_a String
39
+ expect(loaded_object).to be_a EenyMeeny::Variation
40
+ expect(loaded_object.id).to eql(:a)
41
+ expect(loaded_object.name).to eq('A')
42
+ expect(loaded_object.weight).to eq(0.5)
43
+ expect(loaded_object.options[:custom_option_1]).to eq('asd1')
44
+ end
45
+ end
46
+ end
47
+
48
+
49
+ end
@@ -0,0 +1,10 @@
1
+ :my_page:
2
+ :name: My Page
3
+ :version: 1
4
+ :variations:
5
+ :old:
6
+ :name: Old My Page
7
+ :weight: 0.02
8
+ :new:
9
+ :name: New My Page
10
+ :weight: 0.98
@@ -0,0 +1,19 @@
1
+ class MockRackApp
2
+
3
+ attr_reader :request_body
4
+
5
+ def initialize
6
+ @request_headers = {}
7
+ end
8
+
9
+ def call(env)
10
+ @env = env
11
+ @request_body = env['rack.input'].read
12
+ [200, {'Content-Type' => 'text/plain'}, ['OK']]
13
+ end
14
+
15
+ def [](key)
16
+ @env[key]
17
+ end
18
+
19
+ end
@@ -0,0 +1,24 @@
1
+ require 'simplecov'
2
+ require 'simplecov-rcov'
3
+ require 'codeclimate-test-reporter'
4
+
5
+ SimpleCov.start do
6
+ formatter SimpleCov::Formatter::MultiFormatter[
7
+ SimpleCov::Formatter::HTMLFormatter,
8
+ SimpleCov::Formatter::RcovFormatter,
9
+ CodeClimate::TestReporter::Formatter
10
+ ]
11
+ add_group('EenyMeeny', 'lib/eeny-meeny')
12
+ add_group('Specs', 'spec')
13
+ end
14
+
15
+ require 'rspec'
16
+ require 'yaml'
17
+ require 'eeny-meeny'
18
+ require 'mock_rack_app'
19
+
20
+ RSpec.configure do |config|
21
+ config.run_all_when_everything_filtered = true
22
+ config.filter_run :focus
23
+ config.order = "random"
24
+ end
metadata ADDED
@@ -0,0 +1,177 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: eeny-meeny
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Christian Orthmann
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-07-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rake
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '10'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '10'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: simplecov
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov-rcov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rack
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 1.2.1
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '2'
93
+ type: :runtime
94
+ prerelease: false
95
+ version_requirements: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: 1.2.1
100
+ - - "<"
101
+ - !ruby/object:Gem::Version
102
+ version: '2'
103
+ - !ruby/object:Gem::Dependency
104
+ name: activesupport
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 3.0.0
110
+ - - "<"
111
+ - !ruby/object:Gem::Version
112
+ version: 5.0.0
113
+ type: :runtime
114
+ prerelease: false
115
+ version_requirements: !ruby/object:Gem::Requirement
116
+ requirements:
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 3.0.0
120
+ - - "<"
121
+ - !ruby/object:Gem::Version
122
+ version: 5.0.0
123
+ description:
124
+ email: christian.orthmann@gmail.com
125
+ executables: []
126
+ extensions: []
127
+ extra_rdoc_files: []
128
+ files:
129
+ - CHANGELOG.md
130
+ - Gemfile
131
+ - LICENSE
132
+ - README.md
133
+ - Rakefile
134
+ - eeny-meeny.gemspec
135
+ - lib/eeny-meeny.rb
136
+ - lib/eeny-meeny/encryptor.rb
137
+ - lib/eeny-meeny/experiment.rb
138
+ - lib/eeny-meeny/experiment_helper.rb
139
+ - lib/eeny-meeny/middleware.rb
140
+ - lib/eeny-meeny/middleware_helper.rb
141
+ - lib/eeny-meeny/railtie.rb
142
+ - lib/eeny-meeny/route_constraint.rb
143
+ - lib/eeny-meeny/shared_methods.rb
144
+ - lib/eeny-meeny/variation.rb
145
+ - lib/eeny-meeny/version.rb
146
+ - spec/eeny-meeny/experiment_spec.rb
147
+ - spec/eeny-meeny/middleware_spec.rb
148
+ - spec/eeny-meeny/variation_spec.rb
149
+ - spec/fixtures/experiments.yml
150
+ - spec/mock_rack_app.rb
151
+ - spec/spec_helper.rb
152
+ homepage: http://rubygems.org/gems/eeny-meeny
153
+ licenses:
154
+ - MIT
155
+ metadata: {}
156
+ post_install_message:
157
+ rdoc_options: []
158
+ require_paths:
159
+ - lib
160
+ required_ruby_version: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - ">="
163
+ - !ruby/object:Gem::Version
164
+ version: '0'
165
+ required_rubygems_version: !ruby/object:Gem::Requirement
166
+ requirements:
167
+ - - ">="
168
+ - !ruby/object:Gem::Version
169
+ version: '0'
170
+ requirements: []
171
+ rubyforge_project:
172
+ rubygems_version: 2.4.8
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: A simple split testing tool for Rails
176
+ test_files: []
177
+ has_rdoc: