eeny-meeny 1.0.0 → 2.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.
@@ -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