eeny-meeny 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: