eeny-meeny 1.0.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,4 +1,4 @@
1
- require 'eeny-meeny/variation'
1
+ require 'eeny-meeny/models/variation'
2
2
  require 'active_support/time'
3
3
  require 'active_support/core_ext/enumerable'
4
4
 
@@ -6,6 +6,17 @@ module EenyMeeny
6
6
  class Experiment
7
7
  attr_reader :id, :name, :version, :variations, :total_weight, :end_at, :start_at
8
8
 
9
+ def self.find_all
10
+ EenyMeeny.config.experiments.map do |id, experiment|
11
+ new(id, **experiment)
12
+ end
13
+ end
14
+
15
+ def self.find_by_id(experiment_id)
16
+ experiment = EenyMeeny.config.experiments[experiment_id.to_sym]
17
+ new(experiment_id, **experiment) if experiment
18
+ end
19
+
9
20
  def initialize(id, name: '', version: 1, variations: {}, start_at: nil, end_at: nil)
10
21
  @id = id
11
22
  @name = name
@@ -14,11 +25,17 @@ module EenyMeeny
14
25
  Variation.new(variation_id, **variation)
15
26
  end
16
27
  @total_weight = (@variations.empty? ? 1.0 : @variations.sum { |variation| variation.weight.to_f })
17
-
18
28
  @start_at = Time.zone.parse(start_at) if start_at
19
29
  @end_at = Time.zone.parse(end_at) if end_at
20
30
  end
21
31
 
32
+ def active?(now = Time.zone.now)
33
+ return true if @start_at.nil? && @end_at.nil?
34
+ return true if @end_at.nil? && (@start_at && (now > @start_at)) # specified start - open-ended
35
+ return true if @start_at.nil? && (@end_at && (now < @end_at)) # unspecified start - specified end
36
+ !!((@start_at && (now > @start_at)) && (@end_at && (now < @end_at))) # specified start and end
37
+ end
38
+
22
39
  def pick_variation
23
40
  Hash[
24
41
  @variations.map do |variation|
@@ -1,27 +1,28 @@
1
+ require 'eeny-meeny'
1
2
  require 'eeny-meeny/experiment_helper'
2
3
  require 'eeny-meeny/middleware'
3
4
 
4
5
  module EenyMeeny
5
6
  class Railtie < Rails::Railtie
6
7
  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
8
 
15
- initializer 'eeny_meeny.initialize' do |app|
9
+ initializer 'eeny_meeny.configure' do |app|
10
+ # Configrue EenyMeeny (defaults set in eeny_meeny.rb)
11
+ EenyMeeny.configure do |config|
12
+ config.cookies = app.config.eeny_meeny[:cookies] if app.config.eeny_meeny.has_key?(:cookies)
13
+ config.experiments = app.config.eeny_meeny[:experiments] if app.config.eeny_meeny.has_key?(:experiments)
14
+ config.secret = app.config.eeny_meeny[:secret] if app.config.eeny_meeny.has_key?(:secret)
15
+ config.secure = app.config.eeny_meeny[:secure] if app.config.eeny_meeny.has_key?(:secure)
16
+ end
17
+ # Include Helpers in ActionController and ActionView
16
18
  ActionController::Base.send :include, EenyMeeny::ExperimentHelper
17
19
  ActionView::Base.send :include, EenyMeeny::ExperimentHelper
20
+ # Insert Middleware
21
+ app.middleware.insert_before 'ActionDispatch::Cookies', EenyMeeny::Middleware
22
+ end
18
23
 
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
24
+ rake_tasks do
25
+ load 'tasks/cookie.rake'
25
26
  end
26
27
  end
27
28
  end
@@ -0,0 +1,19 @@
1
+ require 'eeny-meeny/models/cookie'
2
+ require 'eeny-meeny/models/experiment'
3
+
4
+ module EenyMeeny
5
+ class ExperimentConstraint
6
+
7
+ def initialize(experiment_id, variation_id: nil)
8
+ @experiment = EenyMeeny::Experiment.find_by_id(experiment_id)
9
+ @variation_id = variation_id
10
+ end
11
+
12
+ def matches?(request)
13
+ return false unless @experiment.active?
14
+ cookie = EenyMeeny::Cookie.read(request.cookie_jar[EenyMeeny::Cookie.cookie_name(@experiment)])
15
+ return false if cookie.nil? # Not participating in experiment
16
+ (@variation_id.nil? || @variation_id == cookie[:variation].id)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ require 'eeny-meeny/models/cookie'
2
+
3
+ module EenyMeeny
4
+ class SmokeTestConstraint
5
+
6
+ def initialize(smoke_test_id, version: 1)
7
+ @smoke_test_cookie_name = EenyMeeny::Cookie.smoke_test_name(smoke_test_id, version: version)
8
+ end
9
+
10
+ def matches?(request)
11
+ cookie = EenyMeeny::Cookie.read(request.cookie_jar[@smoke_test_cookie_name])
12
+ !cookie.nil?
13
+ end
14
+ end
15
+ end
@@ -1,3 +1,3 @@
1
1
  module EenyMeeny
2
- VERSION = '1.0.0'
2
+ VERSION = '2.0.0'
3
3
  end
@@ -0,0 +1,48 @@
1
+ require 'rake'
2
+ require 'eeny-meeny/models/cookie'
3
+ require 'eeny-meeny/models/experiment'
4
+
5
+ def write_cookie(experiment_id, variation_id: nil)
6
+ experiment = EenyMeeny::Experiment.find_by_id(experiment_id)
7
+ raise "Experiment with id '#{experiment_id}' not found!" if experiment.nil?
8
+ if variation_id.nil?
9
+ EenyMeeny::Cookie.create_for_experiment(experiment, EenyMeeny.config.cookies)
10
+ else
11
+ EenyMeeny::Cookie.create_for_experiment_variation(experiment, variation_id, EenyMeeny.config.cookies)
12
+ end
13
+ end
14
+
15
+ namespace :eeny_meeny do
16
+
17
+ namespace :cookie do
18
+ desc 'Create a valid EenyMeeny experiment cookie'
19
+ task :experiment, [:experiment_id] => :environment do |t, args|
20
+ raise "Missing 'experiment_id' parameter" if (args['experiment_id'].nil? || args['experiment_id'].empty?)
21
+ experiment_id = args['experiment_id'].to_sym
22
+ cookie = write_cookie(experiment_id)
23
+ puts cookie
24
+ end
25
+
26
+ desc 'Create a valid EenyMeeny experiment cookie for a specific variation'
27
+ task :experiment_variation, [:experiment_id, :variation_id] => :environment do |t, args|
28
+ raise "Missing 'experiment_id' parameter" if (args['experiment_id'].nil? || args['experiment_id'].empty?)
29
+ raise "Missing 'variation_id' parameter" if (args['variation_id'].nil? || args['variation_id'].empty?)
30
+ experiment_id = args['experiment_id'].to_sym
31
+ variation_id = args['variation_id'].to_sym
32
+ cookie = write_cookie(experiment_id, variation_id: variation_id)
33
+ puts cookie
34
+ end
35
+
36
+ desc 'Create a valid EenyMeeny smoke test cookie'
37
+ task :smoke_test, [:smoke_test_id, :version] => :environment do |t, args|
38
+ raise "Missing 'smoke_test_id' parameter" if (args['smoke_test_id'].nil? || args['smoke_test_id'].empty?)
39
+ smoke_test_id = args['smoke_test_id']
40
+ version = args['version'] || 1
41
+ cookie = EenyMeeny::Cookie.create_for_smoke_test(smoke_test_id, version: version)
42
+ puts cookie
43
+ end
44
+ end
45
+
46
+
47
+
48
+ end
@@ -1,35 +1,32 @@
1
1
  require 'spec_helper'
2
- require 'eeny-meeny/encryptor'
2
+ require 'eeny-meeny/models/encryptor'
3
3
  require 'eeny-meeny/middleware'
4
4
 
5
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)
6
+ EenyMeeny.reset!
7
+ EenyMeeny.configure do |config|
8
+ config.cookies = { path: path, same_site: same_site }
9
+ config.experiments = YAML.load_file(File.join('spec','fixtures','experiments.yml'))
10
+ config.secret = secret
11
+ config.secure = secure
12
+ end
13
+ described_class.new(app)
8
14
  end
9
15
 
10
16
  describe EenyMeeny::Middleware do
11
17
 
12
18
  let(:app) { MockRackApp.new }
13
- before(:example) do
14
- allow(Time).to receive_message_chain(:zone, :now) { Time.now }
15
- end
16
19
 
17
20
  describe 'when initialized' do
18
21
 
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
22
+ subject { initialize_app }
23
+
24
+ it 'sets the experiments' do
25
+ expect(subject.instance_variable_get(:@experiments)).to be
25
26
  end
26
27
 
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
28
+ it 'sets the cookie config' do
29
+ expect(subject.instance_variable_get(:@cookie_config)).to be
33
30
  end
34
31
  end
35
32
 
@@ -0,0 +1,137 @@
1
+ require 'spec_helper'
2
+ require 'eeny-meeny/models/cookie'
3
+ require 'eeny-meeny/models/experiment'
4
+
5
+ describe EenyMeeny::Cookie do
6
+ describe 'when initialized' do
7
+ subject do
8
+ described_class.new(name: 'test', value: '12345')
9
+ end
10
+
11
+ it 'sets the instance variables correctly' do
12
+ expect(subject.name).to eq('test')
13
+ expect(subject.value).to eq('12345')
14
+ expect(subject.expires).to be
15
+ expect(subject.httponly).to be
16
+ expect(subject.same_site).to be_nil
17
+ expect(subject.path).to be_nil
18
+ end
19
+
20
+ describe '#to_h' do
21
+ it 'returns cookie options' do
22
+ options = subject.to_h
23
+ expect(options).to be_a Hash
24
+ expect(options.keys.sort).to eq([:value, :httponly, :expires].sort)
25
+ end
26
+ end
27
+ end
28
+
29
+ describe '.create_for_smoke_test' do
30
+ context 'given a smoke test id' do
31
+ it 'creates a cookie' do
32
+ instance = described_class.create_for_smoke_test(:shadow)
33
+ expect(instance).to be_a EenyMeeny::Cookie
34
+ expect(instance.name).to eq(described_class.smoke_test_name(:shadow))
35
+ end
36
+ end
37
+ end
38
+
39
+ describe '.create_for_experiment', experiments: true do
40
+ context 'given an experiment' do
41
+ it 'creates a cookie' do
42
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
43
+ instance = described_class.create_for_experiment(experiment)
44
+ expect(instance).to be_a EenyMeeny::Cookie
45
+ expect(instance.name).to eq(described_class.cookie_name(experiment))
46
+ end
47
+
48
+ context 'and given cookie options' do
49
+ it 'creates a cookie with the given options' do
50
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
51
+ instance = described_class.create_for_experiment(experiment, same_site: :fun_stuff)
52
+ expect(instance).to be_a EenyMeeny::Cookie
53
+ expect(instance.name).to eq(described_class.cookie_name(experiment))
54
+ expect(instance.same_site).to eq(:fun_stuff)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ describe '.create_for_experiment_variation', experiments: true do
61
+ context 'given an experiment and an variation id' do
62
+ it 'creates a cookie for that variation' do
63
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
64
+ instance = described_class.create_for_experiment_variation(experiment, :new)
65
+ expect(instance).to be_a EenyMeeny::Cookie
66
+ expect(instance.name).to eq(described_class.cookie_name(experiment))
67
+ expect(described_class.read(instance.value)[:variation].id).to eq(:new)
68
+ end
69
+
70
+ context 'and given cookie options' do
71
+ it 'creates a cookie for that variation with the given options' do
72
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
73
+ instance = described_class.create_for_experiment_variation(experiment, :new, same_site: :fun_stuff)
74
+ expect(instance).to be_a EenyMeeny::Cookie
75
+ expect(instance.name).to eq(described_class.cookie_name(experiment))
76
+ expect(instance.same_site).to eq(:fun_stuff)
77
+ expect(described_class.read(instance.value)[:variation].id).to eq(:new)
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ describe '.smoke_test_name' do
84
+ context 'given a smoke_test_id' do
85
+ it 'returns the smoke test cookie name' do
86
+ expect(described_class.smoke_test_name(:something)).to eq('smoke_test_something_v1')
87
+ end
88
+ end
89
+ end
90
+
91
+ describe '.cookie_name', experiments: true do
92
+ context 'given an experiment' do
93
+ it 'returns the experiment cookie name' do
94
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
95
+ expect(described_class.cookie_name(experiment)).to eq('eeny_meeny_my_page_v1')
96
+ end
97
+ end
98
+ end
99
+
100
+ describe '.read', experiments: true do
101
+ context 'given an empty cookie string' do
102
+ it 'returns nil' do
103
+ expect(described_class.read(nil)).to be_nil
104
+ expect(described_class.read('')).to be_nil
105
+ end
106
+ end
107
+
108
+ context 'when EenyMeeny.config.secure = true' do
109
+ context 'and given a valid cookie string' do
110
+ it 'decrypts the string and returns the cookie hash' do
111
+ valid_cookie_string = 'DhtZMLAtVpWiruuq6BdQ+YEeDTK4G1p0HQLeyKFZd2+fD8YyT004p56S03yXsE/kzCASnD9O1sk1tsIHDZ8W2gq+5zQD3fu2aqqLm461FOfy0En4/LqHCP0J0VYol3Py0BlhepjSGDuJrRU7TdKZWsG2/dCiMVLjMf0Pt00NZWooUvQfRz9SCzaFL0mywoVrY1ErKKQCNEPmLREaxavCng=='
112
+ expect(described_class.read(valid_cookie_string)).to be_a Hash
113
+ end
114
+ end
115
+
116
+ context 'and given an invalid cookie string' do
117
+ it 'returns nil' do
118
+ expect(described_class.read('qwedasdafagasdaasdasd')).to be_nil
119
+ end
120
+ end
121
+ end
122
+
123
+ context 'when EenyMeeny.config.secure = false' do
124
+ context 'and given a valid cookie string' do
125
+ it 'returns the cookie hash' do
126
+ EenyMeeny.configure do |config|
127
+ config.secure = false
128
+ config.experiments = YAML.load_file(File.join('spec','fixtures','experiments.yml'))
129
+ end
130
+ experiment = EenyMeeny::Experiment.find_by_id(:my_page)
131
+ valid_cookie_string = described_class.create_for_experiment(experiment).value
132
+ expect(described_class.read(valid_cookie_string)).to be_a Hash
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,181 @@
1
+ require 'spec_helper'
2
+ require 'eeny-meeny/models/experiment'
3
+ require 'eeny-meeny/models/variation'
4
+
5
+ def experiment_with_time(time = {})
6
+ experiment_options = {
7
+ name: 'Test 1',
8
+ variations: {
9
+ a: { name: 'A' },
10
+ b: { name: 'B' }}
11
+ }.merge(time)
12
+ described_class.new(:experiment_1,
13
+ **experiment_options)
14
+ end
15
+
16
+ describe EenyMeeny::Experiment do
17
+ describe 'when initialized' do
18
+
19
+ context 'with weighted variations' do
20
+ subject do
21
+ described_class.new(:experiment_1,
22
+ name: 'Test 1',
23
+ variations: {
24
+ a: { name: 'A', weight: 0.5 },
25
+ b: { name: 'B', weight: 0.3 }})
26
+ end
27
+
28
+ it 'sets the instance variables' do
29
+ expect(subject.id).to eq(:experiment_1)
30
+ expect(subject.name).to eq('Test 1')
31
+ expect(subject.variations).to be_a Array
32
+ expect(subject.variations.size).to eq(2)
33
+ end
34
+
35
+ it "has a 'total_weight' equal to the sum of the variation weights" do
36
+ expect(subject.total_weight).to eq(0.8)
37
+ end
38
+
39
+ describe '#pick_variation' do
40
+ it 'picks a variation' do
41
+ expect(subject.pick_variation).to be_a EenyMeeny::Variation
42
+ end
43
+ end
44
+ end
45
+
46
+ context 'with non-weighted variations' do
47
+ subject do
48
+ described_class.new(:experiment_1,
49
+ name: 'Test 1',
50
+ variations: {
51
+ a: { name: 'A' },
52
+ b: { name: 'B' }})
53
+ end
54
+
55
+ it 'sets the instance variables' do
56
+ expect(subject.id).to eq(:experiment_1)
57
+ expect(subject.name).to eq('Test 1')
58
+ expect(subject.variations).to be_a Array
59
+ expect(subject.variations.size).to eq(2)
60
+ end
61
+
62
+ it "has a 'total_weight' equal to the number of the variation weights" do
63
+ expect(subject.total_weight).to eq(2)
64
+ end
65
+
66
+ describe '#pick_variation' do
67
+ it 'picks a variation' do
68
+ expect(subject.pick_variation).to be_a EenyMeeny::Variation
69
+ end
70
+ end
71
+
72
+ describe '#active?' do
73
+ context 'when the experiment neither have a start_at or end_at time' do
74
+ it 'returns true' do
75
+ expect(subject.active?).to be true
76
+ end
77
+ end
78
+
79
+ context 'when the experiment only have an end_at time' do
80
+ context 'and the current time < end_at' do
81
+ it 'returns true' do
82
+ instance = experiment_with_time(end_at: (Time.zone.now+3600).iso8601)
83
+ expect(instance.active?).to be true
84
+ end
85
+ end
86
+
87
+ context 'and the current time > end_at' do
88
+ it 'returns false' do
89
+ instance = experiment_with_time(end_at: (Time.zone.now-3600).iso8601)
90
+ expect(instance.active?).to be false
91
+ end
92
+ end
93
+ end
94
+
95
+ context 'when the experiment only have a start_at time' do
96
+ context 'and the current time < start_at' do
97
+ it 'returns false' do
98
+ instance = experiment_with_time(start_at: (Time.zone.now+3600).iso8601)
99
+ expect(instance.active?).to be false
100
+ end
101
+ end
102
+
103
+ context 'and the current time > start_at' do
104
+ it 'returns true' do
105
+ instance = experiment_with_time(start_at: (Time.zone.now-3600).iso8601)
106
+ expect(instance.active?).to be true
107
+ end
108
+ end
109
+ end
110
+
111
+ context 'when the experiment both have a start_at and end_at time' do
112
+ context 'and current_time < start_at' do
113
+ it 'returns false' do
114
+ instance = experiment_with_time(start_at: (Time.zone.now+3600).iso8601,
115
+ end_at: (Time.zone.now+7200).iso8601)
116
+ expect(instance.active?).to be false
117
+ end
118
+ end
119
+
120
+ context 'and current_time > start_at and current time < end_at' do
121
+ it 'returns true' do
122
+ instance = experiment_with_time(start_at: (Time.zone.now-3600).iso8601,
123
+ end_at: (Time.zone.now+7200).iso8601)
124
+ expect(instance.active?).to be true
125
+ end
126
+ end
127
+
128
+ context 'and current time > start_at and current time > end_at' do
129
+ it 'returns false' do
130
+ instance = experiment_with_time(start_at: (Time.zone.now-7200).iso8601,
131
+ end_at: (Time.zone.now-3600).iso8601)
132
+ expect(instance.active?).to be false
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ describe '.find_all' do
141
+ context 'when the EenyMeeny is configured with experiments', experiments: true do
142
+ it 'returns those experiments' do
143
+ instances = described_class.find_all
144
+ expect(instances).to be_a Array
145
+ expect(instances.size).to eq(2)
146
+ instances.each do |instance|
147
+ expect(instance).to be_a EenyMeeny::Experiment
148
+ end
149
+ end
150
+ end
151
+
152
+ context 'when EenyMeeny is not configured with experiments' do
153
+ it 'returns an empty array' do
154
+ expect(described_class.find_all).to eq([])
155
+ end
156
+ end
157
+ end
158
+
159
+ describe '.find_by_id' do
160
+ context 'when EenyMeeny is configured with experiments', experiments: true do
161
+ context 'and the given id exists' do
162
+ it 'returns the experiment' do
163
+ expect(described_class.find_by_id(:my_page)).to be_a EenyMeeny::Experiment
164
+ end
165
+ end
166
+
167
+ context 'and the given id does not exist' do
168
+ it 'returns nil' do
169
+ expect(described_class.find_by_id(:experiment_missing)).to be_nil
170
+ end
171
+ end
172
+ end
173
+
174
+ context 'when EenyMeeny is not configured with experiments' do
175
+ it 'returns nil' do
176
+ expect(described_class.find_by_id(:experiment_missing)).to be_nil
177
+ end
178
+ end
179
+ end
180
+
181
+ end