rollout 2.6.2 → 3.2.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 +5 -5
- data/.github/workflows/release.yml +54 -28
- data/.github/workflows/test.yml +148 -6
- data/.gitignore +2 -0
- data/README.md +105 -25
- data/Rakefile +25 -2
- data/docs/upgrading-to-v3.md +110 -0
- data/lib/rollout/feature.rb +28 -29
- data/lib/rollout/feature_state.rb +44 -0
- data/lib/rollout/logging.rb +32 -76
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +65 -52
- data/rollout.gemspec +6 -5
- data/spec/rollout/feature_spec.rb +70 -39
- data/spec/rollout/feature_state_spec.rb +219 -0
- data/spec/rollout/logging_spec.rb +81 -110
- data/spec/rollout/memory_backend_contract_spec.rb +8 -0
- data/spec/rollout_spec.rb +155 -586
- data/spec/spec_helper.rb +83 -10
- data/spec/support/backend_contract.rb +95 -0
- data/spec/support/history_contract.rb +174 -0
- data/spec/support/rollout_backend_integration.rb +77 -0
- metadata +14 -29
- data/.circleci/config.yml +0 -119
data/lib/rollout/feature.rb
CHANGED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'rollout/feature_state'
|
|
4
|
+
|
|
3
5
|
class Rollout
|
|
4
6
|
class Feature
|
|
5
7
|
attr_accessor :groups, :users, :percentage, :data
|
|
6
8
|
attr_reader :name, :options
|
|
7
9
|
|
|
8
|
-
def initialize(
|
|
9
|
-
@name = name
|
|
10
|
+
def initialize(state:, rollout:, options: {}, name: nil)
|
|
10
11
|
@rollout = rollout
|
|
11
12
|
@options = options
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
|
|
15
|
-
@percentage = raw_percentage.to_f
|
|
16
|
-
@users = users_from_string(raw_users)
|
|
17
|
-
@groups = groups_from_string(raw_groups)
|
|
18
|
-
@data = raw_data.nil? || raw_data.strip.empty? ? {} : JSON.parse(raw_data)
|
|
19
|
-
else
|
|
20
|
-
clear
|
|
21
|
-
end
|
|
13
|
+
@name = name.nil? ? state.name.to_sym : name
|
|
14
|
+
assign_state(state)
|
|
22
15
|
end
|
|
23
16
|
|
|
24
|
-
def
|
|
25
|
-
|
|
17
|
+
def to_feature_state
|
|
18
|
+
FeatureState.new(
|
|
19
|
+
name: @name,
|
|
20
|
+
percentage: @percentage,
|
|
21
|
+
users: Array(@users),
|
|
22
|
+
groups: Array(@groups),
|
|
23
|
+
data: @data,
|
|
24
|
+
)
|
|
26
25
|
end
|
|
27
26
|
|
|
28
27
|
def add_user(user)
|
|
@@ -43,10 +42,7 @@ class Rollout
|
|
|
43
42
|
end
|
|
44
43
|
|
|
45
44
|
def clear
|
|
46
|
-
@
|
|
47
|
-
@users = users_from_string('')
|
|
48
|
-
@percentage = 0
|
|
49
|
-
@data = {}
|
|
45
|
+
assign_state(FeatureState.new(name: @name, percentage: 0))
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
def active?(user)
|
|
@@ -77,12 +73,21 @@ class Rollout
|
|
|
77
73
|
c = self.clone
|
|
78
74
|
c.instance_variable_set('@rollout', nil)
|
|
79
75
|
c = Marshal.load(Marshal.dump(c))
|
|
80
|
-
c.instance_variable_set('@
|
|
76
|
+
c.instance_variable_set('@rollout', @rollout)
|
|
81
77
|
c
|
|
82
78
|
end
|
|
83
79
|
|
|
84
80
|
private
|
|
85
81
|
|
|
82
|
+
def assign_state(state)
|
|
83
|
+
state = state.deep_clone
|
|
84
|
+
|
|
85
|
+
@percentage = state.percentage
|
|
86
|
+
@users = users_from_array(state.users)
|
|
87
|
+
@groups = groups_from_array(state.groups)
|
|
88
|
+
@data = state.data
|
|
89
|
+
end
|
|
90
|
+
|
|
86
91
|
def user_id(user)
|
|
87
92
|
if user.is_a?(Integer) || user.is_a?(String)
|
|
88
93
|
user.to_s
|
|
@@ -113,14 +118,8 @@ class Rollout
|
|
|
113
118
|
end
|
|
114
119
|
end
|
|
115
120
|
|
|
116
|
-
def
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
@data.to_json
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def users_from_string(raw_users)
|
|
123
|
-
users = (raw_users || '').split(',').map(&:to_s)
|
|
121
|
+
def users_from_array(users)
|
|
122
|
+
users = Array(users).map(&:to_s)
|
|
124
123
|
if @options[:use_sets]
|
|
125
124
|
users.to_set
|
|
126
125
|
else
|
|
@@ -128,8 +127,8 @@ class Rollout
|
|
|
128
127
|
end
|
|
129
128
|
end
|
|
130
129
|
|
|
131
|
-
def
|
|
132
|
-
groups = (
|
|
130
|
+
def groups_from_array(groups)
|
|
131
|
+
groups = Array(groups).map(&:to_sym)
|
|
133
132
|
if @options[:use_sets]
|
|
134
133
|
groups.to_set
|
|
135
134
|
else
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
class Rollout
|
|
6
|
+
class FeatureState
|
|
7
|
+
attr_reader :name, :percentage, :users, :groups, :data
|
|
8
|
+
|
|
9
|
+
def initialize(name:, percentage:, users: [], groups: [], data: {})
|
|
10
|
+
raise ArgumentError, "data must be a Hash" unless data.is_a?(Hash)
|
|
11
|
+
|
|
12
|
+
@name = name.to_s.dup
|
|
13
|
+
@percentage = percentage.to_f
|
|
14
|
+
@users = Array(users).map { |user| user.to_s.dup }
|
|
15
|
+
@groups = Array(groups).map { |group| group.to_s.dup }
|
|
16
|
+
@data = canonicalize_data(data)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ==(other)
|
|
20
|
+
other.is_a?(FeatureState) &&
|
|
21
|
+
name == other.name &&
|
|
22
|
+
percentage == other.percentage &&
|
|
23
|
+
users == other.users &&
|
|
24
|
+
groups == other.groups &&
|
|
25
|
+
data == other.data
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deep_clone
|
|
29
|
+
self.class.new(
|
|
30
|
+
name: name,
|
|
31
|
+
percentage: percentage,
|
|
32
|
+
users: users,
|
|
33
|
+
groups: groups,
|
|
34
|
+
data: data,
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def canonicalize_data(data)
|
|
41
|
+
JSON.parse(data.to_json)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/rollout/logging.rb
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
class Rollout
|
|
2
4
|
module Logging
|
|
3
5
|
def self.extended(rollout)
|
|
4
6
|
options = rollout.options[:logging]
|
|
5
7
|
options = options.is_a?(Hash) ? options.dup : {}
|
|
6
|
-
options[:storage] ||= rollout.storage
|
|
7
|
-
|
|
8
|
-
logger = Logger.new(**options)
|
|
9
8
|
|
|
10
|
-
rollout.
|
|
9
|
+
logger = Logger.new(adapter: rollout.adapter, **options)
|
|
11
10
|
rollout.define_singleton_method(:logging) do
|
|
12
11
|
logger
|
|
13
12
|
end
|
|
@@ -16,12 +15,6 @@ class Rollout
|
|
|
16
15
|
class Event
|
|
17
16
|
attr_reader :feature, :name, :data, :context, :created_at
|
|
18
17
|
|
|
19
|
-
def self.from_raw(value, score)
|
|
20
|
-
hash = JSON.parse(value, symbolize_names: true)
|
|
21
|
-
|
|
22
|
-
new(**hash.merge(created_at: Time.at(-score.to_f / 1_000_000)))
|
|
23
|
-
end
|
|
24
|
-
|
|
25
18
|
def initialize(feature: nil, name:, data:, context: {}, created_at:)
|
|
26
19
|
@feature = feature
|
|
27
20
|
@name = name
|
|
@@ -53,45 +46,49 @@ class Rollout
|
|
|
53
46
|
end
|
|
54
47
|
|
|
55
48
|
class Logger
|
|
56
|
-
|
|
49
|
+
attr_reader :history_length, :global, :adapter
|
|
50
|
+
|
|
51
|
+
def initialize(adapter:, history_length: 50, global: false)
|
|
52
|
+
@adapter = adapter
|
|
57
53
|
@history_length = history_length
|
|
58
|
-
@storage = storage
|
|
59
54
|
@global = global
|
|
60
55
|
end
|
|
61
56
|
|
|
62
57
|
def updated_at(feature_name)
|
|
63
|
-
|
|
64
|
-
_, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
|
65
|
-
Time.at(-score.to_f / 1_000_000) if score
|
|
58
|
+
@adapter.feature_updated_at(feature_name)
|
|
66
59
|
end
|
|
67
60
|
|
|
68
61
|
def last_event(feature_name)
|
|
69
|
-
|
|
70
|
-
value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
|
71
|
-
Event.from_raw(*value) if value
|
|
62
|
+
events(feature_name, limit: 1).last
|
|
72
63
|
end
|
|
73
64
|
|
|
74
|
-
def events(feature_name)
|
|
75
|
-
|
|
76
|
-
@storage
|
|
77
|
-
.zrange(storage_key, 0, -1, with_scores: true)
|
|
78
|
-
.map { |v| Event.from_raw(*v) }
|
|
79
|
-
.reverse
|
|
65
|
+
def events(feature_name, limit: nil)
|
|
66
|
+
@adapter.feature_events(feature_name, limit: limit)
|
|
80
67
|
end
|
|
81
68
|
|
|
82
|
-
def global_events
|
|
83
|
-
@
|
|
84
|
-
.zrange(global_events_storage_key, 0, -1, with_scores: true)
|
|
85
|
-
.map { |v| Event.from_raw(*v) }
|
|
86
|
-
.reverse
|
|
69
|
+
def global_events(limit: nil)
|
|
70
|
+
@adapter.global_events(limit: limit)
|
|
87
71
|
end
|
|
88
72
|
|
|
89
73
|
def delete(feature_name)
|
|
90
|
-
|
|
91
|
-
@storage.del(storage_key)
|
|
74
|
+
@adapter.delete_feature_events(feature_name)
|
|
92
75
|
end
|
|
93
76
|
|
|
94
|
-
def
|
|
77
|
+
def delete_event_for(feature_name)
|
|
78
|
+
return unless logging_enabled? && global
|
|
79
|
+
|
|
80
|
+
Event.new(
|
|
81
|
+
feature: feature_name.to_s,
|
|
82
|
+
name: :delete,
|
|
83
|
+
data: {},
|
|
84
|
+
context: current_context,
|
|
85
|
+
created_at: Time.now,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def event_for(before, after)
|
|
90
|
+
return unless logging_enabled?
|
|
91
|
+
|
|
95
92
|
before_hash = before.to_hash
|
|
96
93
|
before_hash.delete(:data).each do |k, v|
|
|
97
94
|
before_hash["data.#{k}"] = v
|
|
@@ -116,41 +113,13 @@ class Rollout
|
|
|
116
113
|
|
|
117
114
|
return if changed_count == 0
|
|
118
115
|
|
|
119
|
-
|
|
116
|
+
Event.new(
|
|
120
117
|
feature: after.name,
|
|
121
118
|
name: :update,
|
|
122
119
|
data: change,
|
|
123
120
|
context: current_context,
|
|
124
121
|
created_at: Time.now,
|
|
125
122
|
)
|
|
126
|
-
|
|
127
|
-
storage_key = events_storage_key(after.name)
|
|
128
|
-
|
|
129
|
-
@storage.zadd(storage_key, -event.timestamp, event.serialize)
|
|
130
|
-
@storage.zremrangebyrank(storage_key, @history_length, -1)
|
|
131
|
-
|
|
132
|
-
if @global
|
|
133
|
-
@storage.zadd(global_events_storage_key, -event.timestamp, event.serialize)
|
|
134
|
-
@storage.zremrangebyrank(global_events_storage_key, @history_length, -1)
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def log(event, *args)
|
|
139
|
-
return unless logging_enabled?
|
|
140
|
-
|
|
141
|
-
unless respond_to?(event)
|
|
142
|
-
raise ArgumentError, "Invalid log event: #{event}"
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
expected_arity = method(event).arity
|
|
146
|
-
unless args.count == expected_arity
|
|
147
|
-
raise(
|
|
148
|
-
ArgumentError,
|
|
149
|
-
"Invalid number of arguments for event '#{event}': expected #{expected_arity} but got #{args.count}",
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
public_send(event, *args)
|
|
154
123
|
end
|
|
155
124
|
|
|
156
125
|
CONTEXT_THREAD_KEY = :rollout_logging_context
|
|
@@ -171,29 +140,16 @@ class Rollout
|
|
|
171
140
|
end
|
|
172
141
|
|
|
173
142
|
def without
|
|
143
|
+
previous = Thread.current[WITHOUT_THREAD_KEY]
|
|
174
144
|
Thread.current[WITHOUT_THREAD_KEY] = true
|
|
175
145
|
yield
|
|
176
146
|
ensure
|
|
177
|
-
Thread.current[WITHOUT_THREAD_KEY] =
|
|
147
|
+
Thread.current[WITHOUT_THREAD_KEY] = previous
|
|
178
148
|
end
|
|
179
149
|
|
|
180
150
|
def logging_enabled?
|
|
181
151
|
!Thread.current[WITHOUT_THREAD_KEY]
|
|
182
152
|
end
|
|
183
|
-
|
|
184
|
-
private
|
|
185
|
-
|
|
186
|
-
def global_events_storage_key
|
|
187
|
-
"feature:_global_:logging:events"
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def events_storage_key(feature_name)
|
|
191
|
-
"feature:#{feature_name}:logging:events"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
def current_timestamp
|
|
195
|
-
(Time.now.to_f * 1_000_000).to_i
|
|
196
|
-
end
|
|
197
153
|
end
|
|
198
154
|
end
|
|
199
155
|
end
|
data/lib/rollout/version.rb
CHANGED
data/lib/rollout.rb
CHANGED
|
@@ -13,14 +13,14 @@ class Rollout
|
|
|
13
13
|
|
|
14
14
|
RAND_BASE = (2**32 - 1) / 100.0
|
|
15
15
|
|
|
16
|
-
attr_reader :options, :
|
|
16
|
+
attr_reader :options, :adapter
|
|
17
17
|
|
|
18
|
-
def initialize(
|
|
19
|
-
@
|
|
20
|
-
@options =
|
|
18
|
+
def initialize(adapter:, **options)
|
|
19
|
+
@adapter = adapter
|
|
20
|
+
@options = options
|
|
21
21
|
@groups = { all: ->(_user) { true } }
|
|
22
22
|
|
|
23
|
-
extend(Logging) if
|
|
23
|
+
extend(Logging) if options[:logging]
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def groups
|
|
@@ -38,12 +38,16 @@ class Rollout
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def delete(feature)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
return @adapter.delete_feature(feature) unless respond_to?(:logging)
|
|
42
|
+
|
|
43
|
+
if @adapter.respond_to?(:delete_feature_with_history)
|
|
44
|
+
@adapter.delete_feature_with_history(
|
|
45
|
+
feature,
|
|
46
|
+
event: logging.delete_event_for(feature),
|
|
47
|
+
history_length: logging.history_length,
|
|
48
|
+
)
|
|
49
|
+
else
|
|
50
|
+
@adapter.delete_feature(feature)
|
|
47
51
|
logging.delete(feature)
|
|
48
52
|
end
|
|
49
53
|
end
|
|
@@ -137,8 +141,12 @@ class Rollout
|
|
|
137
141
|
end
|
|
138
142
|
|
|
139
143
|
def get(feature)
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
Feature.new(
|
|
145
|
+
state: @adapter.fetch_feature(feature),
|
|
146
|
+
rollout: self,
|
|
147
|
+
options: @options,
|
|
148
|
+
name: feature,
|
|
149
|
+
)
|
|
142
150
|
end
|
|
143
151
|
|
|
144
152
|
def set_feature_data(feature, data)
|
|
@@ -156,18 +164,13 @@ class Rollout
|
|
|
156
164
|
def multi_get(*features)
|
|
157
165
|
return [] if features.empty?
|
|
158
166
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.mget(*feature_keys)
|
|
163
|
-
.map
|
|
164
|
-
.with_index do |string, index|
|
|
165
|
-
Feature.new(features[index], state: string, rollout: self, options: @options)
|
|
166
|
-
end
|
|
167
|
+
@adapter.fetch_features(features).zip(features).map do |state, name|
|
|
168
|
+
Feature.new(state: state, rollout: self, options: @options, name: name)
|
|
169
|
+
end
|
|
167
170
|
end
|
|
168
171
|
|
|
169
172
|
def features
|
|
170
|
-
|
|
173
|
+
@adapter.feature_names.map(&:to_sym)
|
|
171
174
|
end
|
|
172
175
|
|
|
173
176
|
def feature_states(user = nil)
|
|
@@ -185,49 +188,59 @@ class Rollout
|
|
|
185
188
|
def clear!
|
|
186
189
|
features.each do |feature|
|
|
187
190
|
with_feature(feature, &:clear)
|
|
188
|
-
@
|
|
191
|
+
@adapter.delete_feature(feature)
|
|
189
192
|
end
|
|
190
|
-
|
|
191
|
-
@storage.del(features_key)
|
|
193
|
+
@adapter.clear_features
|
|
192
194
|
end
|
|
193
195
|
|
|
194
196
|
def exists?(feature)
|
|
195
|
-
|
|
196
|
-
# https://github.com/redis/redis-rb/pull/918
|
|
197
|
-
if @storage.respond_to?(:exists?)
|
|
198
|
-
@storage.exists?(key(feature))
|
|
199
|
-
else
|
|
200
|
-
@storage.exists(key(feature))
|
|
201
|
-
end
|
|
197
|
+
@adapter.feature_exists?(feature)
|
|
202
198
|
end
|
|
203
199
|
|
|
204
200
|
def with_feature(feature)
|
|
205
|
-
|
|
201
|
+
mutated = nil
|
|
202
|
+
before = nil
|
|
203
|
+
capture_logging = logging_capture?
|
|
204
|
+
notify = count_observers > 0
|
|
205
|
+
snapshot = notify || capture_logging
|
|
206
|
+
|
|
207
|
+
@adapter.mutate_feature(feature) do |current_state|
|
|
208
|
+
mutated = Feature.new(
|
|
209
|
+
state: current_state,
|
|
210
|
+
rollout: self,
|
|
211
|
+
options: @options,
|
|
212
|
+
name: feature,
|
|
213
|
+
)
|
|
214
|
+
before = mutated.deep_clone if snapshot
|
|
215
|
+
yield mutated
|
|
216
|
+
|
|
217
|
+
state = mutated.to_feature_state
|
|
218
|
+
mutated = Feature.new(
|
|
219
|
+
state: state,
|
|
220
|
+
rollout: self,
|
|
221
|
+
options: @options,
|
|
222
|
+
name: feature,
|
|
223
|
+
)
|
|
224
|
+
event = capture_logging ? logging.event_for(before, mutated) : nil
|
|
225
|
+
result = { state: state, event: event }
|
|
226
|
+
if event
|
|
227
|
+
result[:history_length] = logging.history_length
|
|
228
|
+
result[:global] = logging.global
|
|
229
|
+
end
|
|
230
|
+
result
|
|
231
|
+
end
|
|
206
232
|
|
|
207
|
-
if
|
|
208
|
-
before = f.deep_clone
|
|
209
|
-
yield(f)
|
|
210
|
-
save(f)
|
|
233
|
+
if notify
|
|
211
234
|
changed
|
|
212
|
-
notify_observers(:update, before,
|
|
213
|
-
else
|
|
214
|
-
yield(f)
|
|
215
|
-
save(f)
|
|
235
|
+
notify_observers(:update, before, mutated)
|
|
216
236
|
end
|
|
217
|
-
end
|
|
218
237
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def key(name)
|
|
222
|
-
"feature:#{name}"
|
|
238
|
+
mutated
|
|
223
239
|
end
|
|
224
240
|
|
|
225
|
-
|
|
226
|
-
'feature:__features__'
|
|
227
|
-
end
|
|
241
|
+
private
|
|
228
242
|
|
|
229
|
-
def
|
|
230
|
-
|
|
231
|
-
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
|
243
|
+
def logging_capture?
|
|
244
|
+
respond_to?(:logging) && logging.logging_enabled?
|
|
232
245
|
end
|
|
233
246
|
end
|
data/rollout.gemspec
CHANGED
|
@@ -8,12 +8,14 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.version = Rollout::VERSION
|
|
9
9
|
spec.authors = ['James Golick']
|
|
10
10
|
spec.email = ['jamesgolick@gmail.com']
|
|
11
|
-
spec.description = 'Feature flippers
|
|
12
|
-
spec.summary = 'Feature flippers
|
|
11
|
+
spec.description = 'Feature flippers.'
|
|
12
|
+
spec.summary = 'Feature flippers.'
|
|
13
13
|
spec.homepage = 'https://github.com/FetLife/rollout'
|
|
14
14
|
spec.license = 'MIT'
|
|
15
15
|
|
|
16
|
-
spec.files
|
|
16
|
+
spec.files = `git ls-files`.split("\n").reject do |file|
|
|
17
|
+
file.start_with?('rollout-redis-adapter/', 'rollout-active_record-adapter/')
|
|
18
|
+
end
|
|
17
19
|
spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
|
18
20
|
spec.executables = `git ls-files -- bin/*`.split("\n").map { |f| File.basename(f) }
|
|
19
21
|
spec.require_paths = ['lib']
|
|
@@ -21,10 +23,9 @@ Gem::Specification.new do |spec|
|
|
|
21
23
|
spec.required_ruby_version = '>= 2.3'
|
|
22
24
|
|
|
23
25
|
spec.add_dependency 'observer'
|
|
24
|
-
spec.add_dependency 'redis', '>= 4.0', '< 6'
|
|
25
26
|
|
|
26
27
|
spec.add_development_dependency 'rake'
|
|
27
|
-
spec.add_development_dependency 'bundler', '>= 1
|
|
28
|
+
spec.add_development_dependency 'bundler', '>= 2.1'
|
|
28
29
|
spec.add_development_dependency 'pry'
|
|
29
30
|
spec.add_development_dependency 'rspec', '~> 3.13'
|
|
30
31
|
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.6'
|
|
@@ -1,54 +1,85 @@
|
|
|
1
1
|
require "spec_helper"
|
|
2
2
|
|
|
3
3
|
describe "Rollout::Feature" do
|
|
4
|
-
|
|
4
|
+
def build_feature(name: :chat, percentage: 0, users: [], groups: [], data: {}, options: {}, rollout: nil)
|
|
5
|
+
rollout ||= Object.new
|
|
6
|
+
Rollout::Feature.new(
|
|
7
|
+
state: Rollout::FeatureState.new(
|
|
8
|
+
name: name,
|
|
9
|
+
percentage: percentage,
|
|
10
|
+
users: users,
|
|
11
|
+
groups: groups,
|
|
12
|
+
data: data,
|
|
13
|
+
),
|
|
14
|
+
rollout: rollout,
|
|
15
|
+
options: options,
|
|
16
|
+
)
|
|
17
|
+
end
|
|
5
18
|
|
|
6
19
|
describe "#add_user" do
|
|
7
20
|
it "ids a user using id_user_by" do
|
|
8
|
-
user
|
|
9
|
-
feature =
|
|
21
|
+
user = double("User", email: "test@test.com")
|
|
22
|
+
feature = build_feature(options: { id_user_by: :email })
|
|
10
23
|
feature.add_user(user)
|
|
11
24
|
expect(user).to have_received :email
|
|
12
25
|
end
|
|
13
26
|
end
|
|
14
27
|
|
|
15
28
|
describe "#initialize" do
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
feature = Rollout::Feature.new(:chat, rollout: rollout)
|
|
19
|
-
expect(feature.groups).to be_empty
|
|
20
|
-
expect(feature.users).to be_empty
|
|
21
|
-
expect(feature.percentage).to eq 0
|
|
22
|
-
expect(feature.data).to eq({})
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
it 'clears feature attributes when string is nil' do
|
|
26
|
-
feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout)
|
|
27
|
-
expect(feature.groups).to be_empty
|
|
28
|
-
expect(feature.users).to be_empty
|
|
29
|
-
expect(feature.percentage).to eq 0
|
|
30
|
-
expect(feature.data).to eq({})
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
it 'clears feature attributes when string is empty string' do
|
|
34
|
-
feature = Rollout::Feature.new(:chat, state: "", rollout: rollout)
|
|
35
|
-
expect(feature.groups).to be_empty
|
|
36
|
-
expect(feature.users).to be_empty
|
|
37
|
-
expect(feature.percentage).to eq 0
|
|
38
|
-
expect(feature.data).to eq({})
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
describe "when there is no data" do
|
|
42
|
-
it 'sets @data to empty hash' do
|
|
43
|
-
feature = Rollout::Feature.new(:chat, state: "0||", rollout: rollout)
|
|
44
|
-
expect(feature.data).to eq({})
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
it 'sets @data to empty hash' do
|
|
48
|
-
feature = Rollout::Feature.new(:chat, state: "||| ", rollout: rollout)
|
|
49
|
-
expect(feature.data).to eq({})
|
|
50
|
-
end
|
|
51
|
-
end
|
|
29
|
+
it "uses the state's name" do
|
|
30
|
+
expect(build_feature(name: :video).name).to eq :video
|
|
52
31
|
end
|
|
32
|
+
|
|
33
|
+
it "preserves an explicit public name" do
|
|
34
|
+
feature = Rollout::Feature.new(
|
|
35
|
+
state: Rollout::FeatureState.new(name: :chat, percentage: 0),
|
|
36
|
+
rollout: Object.new,
|
|
37
|
+
name: "chat",
|
|
38
|
+
)
|
|
39
|
+
feature.percentage = 50
|
|
40
|
+
feature.clear
|
|
41
|
+
|
|
42
|
+
expect(feature.name).to eq "chat"
|
|
43
|
+
expect(feature.to_feature_state.name).to eq "chat"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "clears feature attributes for an empty state" do
|
|
47
|
+
feature = build_feature
|
|
48
|
+
|
|
49
|
+
expect(feature.groups).to be_empty
|
|
50
|
+
expect(feature.users).to be_empty
|
|
51
|
+
expect(feature.percentage).to eq 0
|
|
52
|
+
expect(feature.data).to eq({})
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe "percentage assignment" do
|
|
57
|
+
it "keeps the same users across features when randomize_percentage is off" do
|
|
58
|
+
chat = build_feature(name: :chat, percentage: 20)
|
|
59
|
+
beta = build_feature(name: :beta, percentage: 20)
|
|
60
|
+
|
|
61
|
+
expect(chat.active?(double(id: 2))).to eq true
|
|
62
|
+
expect(chat.active?(double(id: 6))).to eq true
|
|
63
|
+
expect(chat.active?(double(id: 1))).to eq false
|
|
64
|
+
expect(beta.active?(double(id: 2))).to eq true
|
|
65
|
+
expect(beta.active?(double(id: 1))).to eq false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "changes assignment by feature name when randomize_percentage is on" do
|
|
69
|
+
chat = build_feature(name: :chat, percentage: 20, options: { randomize_percentage: true })
|
|
70
|
+
beta = build_feature(name: :beta, percentage: 20, options: { randomize_percentage: true })
|
|
71
|
+
|
|
72
|
+
expect(chat.active?(double(id: 1))).to eq true
|
|
73
|
+
expect(beta.active?(double(id: 1))).to eq false
|
|
74
|
+
expect(chat.active?(double(id: 5))).to eq false
|
|
75
|
+
expect(beta.active?(double(id: 5))).to eq true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "does not expose assign_state" do
|
|
80
|
+
feature = build_feature
|
|
81
|
+
|
|
82
|
+
expect(feature).not_to respond_to(:assign_state)
|
|
83
|
+
expect(feature.private_methods).to include(:assign_state)
|
|
53
84
|
end
|
|
54
85
|
end
|