rollout 2.4.6 → 2.6.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 +4 -4
- data/.circleci/config.yml +56 -32
- data/README.md +12 -1
- data/lib/rollout/feature.rb +20 -10
- data/lib/rollout/logging.rb +199 -0
- data/lib/rollout/version.rb +1 -1
- data/lib/rollout.rb +42 -11
- data/rollout.gemspec +2 -2
- data/spec/rollout/feature_spec.rb +54 -0
- data/spec/rollout/logging_spec.rb +143 -0
- data/spec/rollout_spec.rb +191 -240
- data/spec/spec_helper.rb +2 -2
- metadata +17 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 58914b07d5596f1db7ce88d65de6e5286519e4b527377b40b7c56ad3ea799323
|
4
|
+
data.tar.gz: 4200f92c9784c3d3f6d275a4308714c7b55d35154c97ef01a89a04166e69c041
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4e3fcddc89ff58fe0d652eed810767eba1a03a4a23312f9bceb174e63348b07fd47592a1504316c3e42169610f24fc78580b846b5649200159dcef6c6ffdbdb7
|
7
|
+
data.tar.gz: 02cc8771d302d5e0b33fb370d6a9ed063f5b7d05406f4ec57689f2196921e5ec4e1382b1a1fd175d7804e316f98538d0e2809e6ad7fe25847e06466dd12cafbc
|
data/.circleci/config.yml
CHANGED
@@ -3,58 +3,64 @@ version: 2.1
|
|
3
3
|
workflows:
|
4
4
|
main:
|
5
5
|
jobs:
|
6
|
+
- ruby33
|
7
|
+
- ruby31
|
8
|
+
- ruby32
|
9
|
+
- ruby30
|
6
10
|
- ruby27
|
7
11
|
- ruby26
|
8
12
|
- ruby25
|
9
13
|
- ruby24
|
10
|
-
- ruby23
|
11
14
|
|
12
15
|
executors:
|
16
|
+
ruby33:
|
17
|
+
docker:
|
18
|
+
- image: cimg/ruby:3.3
|
19
|
+
- image: cimg/redis:7.2
|
20
|
+
ruby32:
|
21
|
+
docker:
|
22
|
+
- image: cimg/ruby:3.2
|
23
|
+
- image: cimg/redis:7.2
|
24
|
+
ruby31:
|
25
|
+
docker:
|
26
|
+
- image: cimg/ruby:3.1
|
27
|
+
- image: cimg/redis:7.2
|
28
|
+
ruby30:
|
29
|
+
docker:
|
30
|
+
- image: cimg/ruby:3.0
|
31
|
+
- image: cimg/redis:7.2
|
13
32
|
ruby27:
|
14
33
|
docker:
|
15
|
-
- image:
|
16
|
-
- image:
|
34
|
+
- image: cimg/ruby:2.7
|
35
|
+
- image: cimg/redis:7.2
|
17
36
|
ruby26:
|
18
37
|
docker:
|
19
|
-
- image:
|
20
|
-
- image:
|
38
|
+
- image: cimg/ruby:2.7
|
39
|
+
- image: cimg/redis:7.2
|
21
40
|
ruby25:
|
22
41
|
docker:
|
23
|
-
- image:
|
24
|
-
- image:
|
42
|
+
- image: cimg/ruby:2.7
|
43
|
+
- image: cimg/redis:7.2
|
25
44
|
ruby24:
|
26
45
|
docker:
|
27
|
-
- image:
|
28
|
-
- image:
|
29
|
-
ruby23:
|
30
|
-
docker:
|
31
|
-
- image: circleci/ruby:2.3
|
32
|
-
- image: circleci/redis:alpine
|
46
|
+
- image: cimg/ruby:2.4
|
47
|
+
- image: cimg/redis:7.2
|
33
48
|
|
34
49
|
commands:
|
35
50
|
test:
|
36
51
|
steps:
|
37
|
-
- restore_cache:
|
38
|
-
keys:
|
39
|
-
- bundler-{{ checksum "Gemfile.lock" }}
|
40
|
-
|
41
52
|
- run:
|
42
53
|
name: Bundle Install
|
43
54
|
command: bundle check --path vendor/bundle || bundle install
|
44
55
|
|
45
|
-
- save_cache:
|
46
|
-
key: bundler-{{ checksum "Gemfile.lock" }}
|
47
|
-
paths:
|
48
|
-
- vendor/bundle
|
49
|
-
|
50
56
|
- run:
|
51
57
|
name: Run rspec
|
52
58
|
command: |
|
53
59
|
bundle exec rspec --format documentation --format RspecJunitFormatter --out test_results/rspec.xml
|
54
60
|
|
55
61
|
jobs:
|
56
|
-
|
57
|
-
executor:
|
62
|
+
ruby33:
|
63
|
+
executor: ruby33
|
58
64
|
steps:
|
59
65
|
- checkout
|
60
66
|
- test
|
@@ -70,26 +76,44 @@ jobs:
|
|
70
76
|
- store_test_results:
|
71
77
|
path: test_results
|
72
78
|
|
73
|
-
|
74
|
-
executor:
|
79
|
+
ruby32:
|
80
|
+
executor: ruby30
|
75
81
|
steps:
|
76
82
|
- checkout
|
77
83
|
- test
|
78
84
|
|
79
|
-
|
80
|
-
executor:
|
85
|
+
ruby31:
|
86
|
+
executor: ruby30
|
81
87
|
steps:
|
82
88
|
- checkout
|
83
89
|
- test
|
84
90
|
|
85
|
-
|
86
|
-
executor:
|
91
|
+
ruby30:
|
92
|
+
executor: ruby30
|
87
93
|
steps:
|
88
94
|
- checkout
|
89
95
|
- test
|
90
96
|
|
91
|
-
|
92
|
-
executor:
|
97
|
+
ruby27:
|
98
|
+
executor: ruby27
|
99
|
+
steps:
|
100
|
+
- checkout
|
101
|
+
- test
|
102
|
+
|
103
|
+
ruby26:
|
104
|
+
executor: ruby27
|
105
|
+
steps:
|
106
|
+
- checkout
|
107
|
+
- test
|
108
|
+
|
109
|
+
ruby25:
|
110
|
+
executor: ruby27
|
111
|
+
steps:
|
112
|
+
- checkout
|
113
|
+
- test
|
114
|
+
|
115
|
+
ruby24:
|
116
|
+
executor: ruby24
|
93
117
|
steps:
|
94
118
|
- checkout
|
95
119
|
- test
|
data/README.md
CHANGED
@@ -28,7 +28,7 @@ or even simpler
|
|
28
28
|
|
29
29
|
```ruby
|
30
30
|
require 'redis'
|
31
|
-
$rollout = Rollout.new(
|
31
|
+
$rollout = Rollout.new($redis) # Will use REDIS_URL env var or default redis url
|
32
32
|
```
|
33
33
|
|
34
34
|
|
@@ -161,6 +161,15 @@ deactivate them automatically when a threshold is reached to prevent service
|
|
161
161
|
failures from cascading. See https://github.com/jamesgolick/degrade for the
|
162
162
|
failure detection code.
|
163
163
|
|
164
|
+
## Check Rollout Feature
|
165
|
+
|
166
|
+
You can inspect the state of your feature using:
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
>> $rollout.get(:chat)
|
170
|
+
=> #<Rollout::Feature:0x00007f99fa4ec528 @data={}, @groups=[:caretakers], @name=:chat, @options={}, @percentage=0.05, @users=["1"]>
|
171
|
+
```
|
172
|
+
|
164
173
|
## Namespacing
|
165
174
|
|
166
175
|
Rollout separates its keys from other keys in the data store using the
|
@@ -180,6 +189,7 @@ This example would use the "development:feature:chat:groups" key.
|
|
180
189
|
|
181
190
|
## Frontend / UI
|
182
191
|
|
192
|
+
* [rollout-ui](https://github.com/fetlife/rollout-ui)
|
183
193
|
* [Rollout-Dashboard](https://github.com/fiverr/rollout_dashboard/)
|
184
194
|
|
185
195
|
## Implementations in other languages
|
@@ -188,6 +198,7 @@ This example would use the "development:feature:chat:groups" key.
|
|
188
198
|
* PHP: https://github.com/opensoft/rollout
|
189
199
|
* Clojure: https://github.com/yeller/shoutout
|
190
200
|
* Perl: https://metacpan.org/pod/Toggle
|
201
|
+
* Golang: https://github.com/SalesLoft/gorollout
|
191
202
|
|
192
203
|
|
193
204
|
## Contributors
|
data/lib/rollout/feature.rb
CHANGED
@@ -5,12 +5,13 @@ class Rollout
|
|
5
5
|
attr_accessor :groups, :users, :percentage, :data
|
6
6
|
attr_reader :name, :options
|
7
7
|
|
8
|
-
def initialize(name,
|
9
|
-
@
|
10
|
-
@
|
8
|
+
def initialize(name, rollout:, state: nil, options: {})
|
9
|
+
@name = name
|
10
|
+
@rollout = rollout
|
11
|
+
@options = options
|
11
12
|
|
12
|
-
if
|
13
|
-
raw_percentage, raw_users, raw_groups, raw_data =
|
13
|
+
if state
|
14
|
+
raw_percentage, raw_users, raw_groups, raw_data = state.split('|', 4)
|
14
15
|
@percentage = raw_percentage.to_f
|
15
16
|
@users = users_from_string(raw_users)
|
16
17
|
@groups = groups_from_string(raw_groups)
|
@@ -48,12 +49,12 @@ class Rollout
|
|
48
49
|
@data = {}
|
49
50
|
end
|
50
51
|
|
51
|
-
def active?(
|
52
|
+
def active?(user)
|
52
53
|
if user
|
53
54
|
id = user_id(user)
|
54
55
|
user_in_percentage?(id) ||
|
55
56
|
user_in_active_users?(id) ||
|
56
|
-
user_in_active_group?(user
|
57
|
+
user_in_active_group?(user)
|
57
58
|
else
|
58
59
|
@percentage == 100
|
59
60
|
end
|
@@ -67,10 +68,19 @@ class Rollout
|
|
67
68
|
{
|
68
69
|
percentage: @percentage,
|
69
70
|
groups: @groups,
|
70
|
-
users: @users
|
71
|
+
users: @users,
|
72
|
+
data: @data,
|
71
73
|
}
|
72
74
|
end
|
73
75
|
|
76
|
+
def deep_clone
|
77
|
+
c = self.clone
|
78
|
+
c.instance_variable_set('@rollout', nil)
|
79
|
+
c = Marshal.load(Marshal.dump(c))
|
80
|
+
c.instance_variable_set('@rollot', @rollout)
|
81
|
+
c
|
82
|
+
end
|
83
|
+
|
74
84
|
private
|
75
85
|
|
76
86
|
def user_id(user)
|
@@ -97,9 +107,9 @@ class Rollout
|
|
97
107
|
end
|
98
108
|
end
|
99
109
|
|
100
|
-
def user_in_active_group?(user
|
110
|
+
def user_in_active_group?(user)
|
101
111
|
@groups.any? do |g|
|
102
|
-
rollout.active_in_group?(g, user)
|
112
|
+
@rollout.active_in_group?(g, user)
|
103
113
|
end
|
104
114
|
end
|
105
115
|
|
@@ -0,0 +1,199 @@
|
|
1
|
+
class Rollout
|
2
|
+
module Logging
|
3
|
+
def self.extended(rollout)
|
4
|
+
options = rollout.options[:logging]
|
5
|
+
options = options.is_a?(Hash) ? options.dup : {}
|
6
|
+
options[:storage] ||= rollout.storage
|
7
|
+
|
8
|
+
logger = Logger.new(**options)
|
9
|
+
|
10
|
+
rollout.add_observer(logger, :log)
|
11
|
+
rollout.define_singleton_method(:logging) do
|
12
|
+
logger
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
class Event
|
17
|
+
attr_reader :feature, :name, :data, :context, :created_at
|
18
|
+
|
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
|
+
def initialize(feature: nil, name:, data:, context: {}, created_at:)
|
26
|
+
@feature = feature
|
27
|
+
@name = name
|
28
|
+
@data = data
|
29
|
+
@context = context
|
30
|
+
@created_at = created_at
|
31
|
+
end
|
32
|
+
|
33
|
+
def timestamp
|
34
|
+
(@created_at.to_f * 1_000_000).to_i
|
35
|
+
end
|
36
|
+
|
37
|
+
def serialize
|
38
|
+
JSON.dump(
|
39
|
+
feature: @feature,
|
40
|
+
name: @name,
|
41
|
+
data: @data,
|
42
|
+
context: @context,
|
43
|
+
created_at: @created_at,
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
47
|
+
def ==(other)
|
48
|
+
feature == other.feature \
|
49
|
+
&& name == other.name \
|
50
|
+
&& data == other.data \
|
51
|
+
&& created_at == other.created_at
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
class Logger
|
56
|
+
def initialize(storage: nil, history_length: 50, global: false)
|
57
|
+
@history_length = history_length
|
58
|
+
@storage = storage
|
59
|
+
@global = global
|
60
|
+
end
|
61
|
+
|
62
|
+
def updated_at(feature_name)
|
63
|
+
storage_key = events_storage_key(feature_name)
|
64
|
+
_, score = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
65
|
+
Time.at(-score.to_f / 1_000_000) if score
|
66
|
+
end
|
67
|
+
|
68
|
+
def last_event(feature_name)
|
69
|
+
storage_key = events_storage_key(feature_name)
|
70
|
+
value = @storage.zrange(storage_key, 0, 0, with_scores: true).first
|
71
|
+
Event.from_raw(*value) if value
|
72
|
+
end
|
73
|
+
|
74
|
+
def events(feature_name)
|
75
|
+
storage_key = events_storage_key(feature_name)
|
76
|
+
@storage
|
77
|
+
.zrange(storage_key, 0, -1, with_scores: true)
|
78
|
+
.map { |v| Event.from_raw(*v) }
|
79
|
+
.reverse
|
80
|
+
end
|
81
|
+
|
82
|
+
def global_events
|
83
|
+
@storage
|
84
|
+
.zrange(global_events_storage_key, 0, -1, with_scores: true)
|
85
|
+
.map { |v| Event.from_raw(*v) }
|
86
|
+
.reverse
|
87
|
+
end
|
88
|
+
|
89
|
+
def delete(feature_name)
|
90
|
+
storage_key = events_storage_key(feature_name)
|
91
|
+
@storage.del(storage_key)
|
92
|
+
end
|
93
|
+
|
94
|
+
def update(before, after)
|
95
|
+
before_hash = before.to_hash
|
96
|
+
before_hash.delete(:data).each do |k, v|
|
97
|
+
before_hash["data.#{k}"] = v
|
98
|
+
end
|
99
|
+
after_hash = after.to_hash
|
100
|
+
after_hash.delete(:data).each do |k, v|
|
101
|
+
after_hash["data.#{k}"] = v
|
102
|
+
end
|
103
|
+
|
104
|
+
keys = before_hash.keys | after_hash.keys
|
105
|
+
change = { before: {}, after: {} }
|
106
|
+
changed_count = 0
|
107
|
+
|
108
|
+
keys.each do |key|
|
109
|
+
next if before_hash[key] == after_hash[key]
|
110
|
+
|
111
|
+
change[:before][key] = before_hash[key]
|
112
|
+
change[:after][key] = after_hash[key]
|
113
|
+
|
114
|
+
changed_count += 1
|
115
|
+
end
|
116
|
+
|
117
|
+
return if changed_count == 0
|
118
|
+
|
119
|
+
event = Event.new(
|
120
|
+
feature: after.name,
|
121
|
+
name: :update,
|
122
|
+
data: change,
|
123
|
+
context: current_context,
|
124
|
+
created_at: Time.now,
|
125
|
+
)
|
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
|
+
end
|
155
|
+
|
156
|
+
CONTEXT_THREAD_KEY = :rollout_logging_context
|
157
|
+
WITHOUT_THREAD_KEY = :rollout_logging_disabled
|
158
|
+
|
159
|
+
def with_context(context)
|
160
|
+
raise ArgumentError, "context must be a Hash" unless context.is_a?(Hash)
|
161
|
+
raise ArgumentError, "block is required" unless block_given?
|
162
|
+
|
163
|
+
Thread.current[CONTEXT_THREAD_KEY] = context
|
164
|
+
yield
|
165
|
+
ensure
|
166
|
+
Thread.current[CONTEXT_THREAD_KEY] = nil
|
167
|
+
end
|
168
|
+
|
169
|
+
def current_context
|
170
|
+
Thread.current[CONTEXT_THREAD_KEY] || {}
|
171
|
+
end
|
172
|
+
|
173
|
+
def without
|
174
|
+
Thread.current[WITHOUT_THREAD_KEY] = true
|
175
|
+
yield
|
176
|
+
ensure
|
177
|
+
Thread.current[WITHOUT_THREAD_KEY] = nil
|
178
|
+
end
|
179
|
+
|
180
|
+
def logging_enabled?
|
181
|
+
!Thread.current[WITHOUT_THREAD_KEY]
|
182
|
+
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
|
+
end
|
198
|
+
end
|
199
|
+
end
|
data/lib/rollout/version.rb
CHANGED
data/lib/rollout.rb
CHANGED
@@ -1,18 +1,30 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'rollout/feature'
|
4
|
+
require 'rollout/logging'
|
4
5
|
require 'rollout/version'
|
5
6
|
require 'zlib'
|
6
7
|
require 'set'
|
7
8
|
require 'json'
|
9
|
+
require 'observer'
|
8
10
|
|
9
11
|
class Rollout
|
12
|
+
include Observable
|
13
|
+
|
10
14
|
RAND_BASE = (2**32 - 1) / 100.0
|
11
15
|
|
16
|
+
attr_reader :options, :storage
|
17
|
+
|
12
18
|
def initialize(storage, opts = {})
|
13
19
|
@storage = storage
|
14
20
|
@options = opts
|
15
21
|
@groups = { all: ->(_user) { true } }
|
22
|
+
|
23
|
+
extend(Logging) if opts[:logging]
|
24
|
+
end
|
25
|
+
|
26
|
+
def groups
|
27
|
+
@groups.keys
|
16
28
|
end
|
17
29
|
|
18
30
|
def activate(feature)
|
@@ -30,6 +42,10 @@ class Rollout
|
|
30
42
|
features.delete(feature.to_s)
|
31
43
|
@storage.set(features_key, features.join(','))
|
32
44
|
@storage.del(key(feature))
|
45
|
+
|
46
|
+
if respond_to?(:logging)
|
47
|
+
logging.delete(feature)
|
48
|
+
end
|
33
49
|
end
|
34
50
|
|
35
51
|
def set(feature, desired_state)
|
@@ -91,7 +107,7 @@ class Rollout
|
|
91
107
|
|
92
108
|
def active?(feature, user = nil)
|
93
109
|
feature = get(feature)
|
94
|
-
feature.active?(
|
110
|
+
feature.active?(user)
|
95
111
|
end
|
96
112
|
|
97
113
|
def user_in_active_users?(feature, user = nil)
|
@@ -122,7 +138,7 @@ class Rollout
|
|
122
138
|
|
123
139
|
def get(feature)
|
124
140
|
string = @storage.get(key(feature))
|
125
|
-
Feature.new(feature, string, @options)
|
141
|
+
Feature.new(feature, state: string, rollout: self, options: @options)
|
126
142
|
end
|
127
143
|
|
128
144
|
def set_feature_data(feature, data)
|
@@ -141,7 +157,13 @@ class Rollout
|
|
141
157
|
return [] if features.empty?
|
142
158
|
|
143
159
|
feature_keys = features.map { |feature| key(feature) }
|
144
|
-
|
160
|
+
|
161
|
+
@storage
|
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
|
145
167
|
end
|
146
168
|
|
147
169
|
def features
|
@@ -150,13 +172,13 @@ class Rollout
|
|
150
172
|
|
151
173
|
def feature_states(user = nil)
|
152
174
|
multi_get(*features).each_with_object({}) do |f, hash|
|
153
|
-
hash[f.name] = f.active?(
|
175
|
+
hash[f.name] = f.active?(user)
|
154
176
|
end
|
155
177
|
end
|
156
178
|
|
157
179
|
def active_features(user = nil)
|
158
180
|
multi_get(*features).select do |f|
|
159
|
-
f.active?(
|
181
|
+
f.active?(user)
|
160
182
|
end.map(&:name)
|
161
183
|
end
|
162
184
|
|
@@ -179,6 +201,21 @@ class Rollout
|
|
179
201
|
end
|
180
202
|
end
|
181
203
|
|
204
|
+
def with_feature(feature)
|
205
|
+
f = get(feature)
|
206
|
+
|
207
|
+
if count_observers > 0
|
208
|
+
before = f.deep_clone
|
209
|
+
yield(f)
|
210
|
+
save(f)
|
211
|
+
changed
|
212
|
+
notify_observers(:update, before, f)
|
213
|
+
else
|
214
|
+
yield(f)
|
215
|
+
save(f)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
182
219
|
private
|
183
220
|
|
184
221
|
def key(name)
|
@@ -189,12 +226,6 @@ class Rollout
|
|
189
226
|
'feature:__features__'
|
190
227
|
end
|
191
228
|
|
192
|
-
def with_feature(feature)
|
193
|
-
f = get(feature)
|
194
|
-
yield(f)
|
195
|
-
save(f)
|
196
|
-
end
|
197
|
-
|
198
229
|
def save(feature)
|
199
230
|
@storage.set(key(feature.name), feature.serialize)
|
200
231
|
@storage.set(features_key, (features | [feature.name.to_sym]).join(','))
|
data/rollout.gemspec
CHANGED
@@ -20,11 +20,11 @@ Gem::Specification.new do |spec|
|
|
20
20
|
|
21
21
|
spec.required_ruby_version = '>= 2.3'
|
22
22
|
|
23
|
-
spec.add_dependency 'redis', '
|
23
|
+
spec.add_dependency 'redis', '>= 4.0', '< 6'
|
24
24
|
|
25
25
|
spec.add_development_dependency 'bundler', '>= 1.17'
|
26
26
|
spec.add_development_dependency 'pry'
|
27
|
-
spec.add_development_dependency 'rspec', '~> 3.
|
27
|
+
spec.add_development_dependency 'rspec', '~> 3.13'
|
28
28
|
spec.add_development_dependency 'rspec_junit_formatter', '~> 0.4'
|
29
29
|
spec.add_development_dependency 'rubocop', '~> 0.71'
|
30
30
|
spec.add_development_dependency 'simplecov', '0.17'
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "spec_helper"
|
2
|
+
|
3
|
+
describe "Rollout::Feature" do
|
4
|
+
let(:rollout) { Rollout.new($redis) }
|
5
|
+
|
6
|
+
describe "#add_user" do
|
7
|
+
it "ids a user using id_user_by" do
|
8
|
+
user = double("User", email: "test@test.com")
|
9
|
+
feature = Rollout::Feature.new(:chat, state: nil, rollout: rollout, options: { id_user_by: :email })
|
10
|
+
feature.add_user(user)
|
11
|
+
expect(user).to have_received :email
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
describe "#initialize" do
|
16
|
+
describe "when string does not exist" do
|
17
|
+
it 'clears feature attributes when string is not given' do
|
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
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|