rollout 2.6.2 → 3.1.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.
@@ -0,0 +1,219 @@
1
+ require "spec_helper"
2
+ require "date"
3
+
4
+ RSpec.describe Rollout::FeatureState do
5
+ def build_state(overrides = {})
6
+ described_class.new(**{
7
+ name: :chat,
8
+ percentage: 25,
9
+ users: [123, "456"],
10
+ groups: [:employees, "supporters"],
11
+ data: { description: "New navigation", "updated_at" => 1 },
12
+ }.merge(overrides))
13
+ end
14
+
15
+ def feature_for(state, options: {}, rollout: Object.new)
16
+ Rollout::Feature.new(state: state, rollout: rollout, options: options)
17
+ end
18
+
19
+ def json_value(value)
20
+ JSON.parse({ "value" => value }.to_json)["value"]
21
+ end
22
+
23
+ it "normalizes persistence types" do
24
+ state = build_state
25
+
26
+ expect(state.name).to eq "chat"
27
+ expect(state.percentage).to eq 25.0
28
+ expect(state.users).to eq %w[123 456]
29
+ expect(state.groups).to eq %w[employees supporters]
30
+ expect(state.data).to eq("description" => "New navigation", "updated_at" => 1)
31
+ end
32
+
33
+ it "does not share collections with the caller" do
34
+ users = ["123"]
35
+ groups = ["employees"]
36
+ nested = ["a"]
37
+ data = { "labels" => nested }
38
+ state = build_state(users: users, groups: groups, data: data)
39
+
40
+ users << "456"
41
+ groups << "supporters"
42
+ nested << "b"
43
+ data["labels"] << "c"
44
+
45
+ expect(state.users).to eq %w[123]
46
+ expect(state.groups).to eq %w[employees]
47
+ expect(state.data).to eq("labels" => ["a"])
48
+ end
49
+
50
+ it "canonicalizes nested JSON-compatible metadata" do
51
+ state = build_state(
52
+ data: {
53
+ "description" => "New navigation",
54
+ "updated_at" => 1,
55
+ "enabled" => true,
56
+ "ratio" => 0.5,
57
+ "owner" => nil,
58
+ "kind" => :beta,
59
+ "labels" => ["a", { "nested" => :ok }],
60
+ },
61
+ )
62
+
63
+ expect(state.data).to eq(
64
+ "description" => "New navigation",
65
+ "updated_at" => 1,
66
+ "enabled" => true,
67
+ "ratio" => 0.5,
68
+ "owner" => nil,
69
+ "kind" => "beta",
70
+ "labels" => ["a", { "nested" => "ok" }],
71
+ )
72
+ end
73
+
74
+ it "canonicalizes Time, Date, and custom JSON values" do
75
+ custom = Object.new
76
+ def custom.to_json(*)
77
+ '"widget"'
78
+ end
79
+
80
+ released_at = Time.utc(2026, 1, 1)
81
+ day = Date.new(2026, 1, 1)
82
+ state = build_state(
83
+ data: {
84
+ "released_at" => released_at,
85
+ "day" => day,
86
+ "item" => custom,
87
+ },
88
+ )
89
+
90
+ expect(state.data).to eq(
91
+ "released_at" => json_value(released_at),
92
+ "day" => json_value(day),
93
+ "item" => "widget",
94
+ )
95
+ end
96
+
97
+ it "canonicalizes BigDecimal through JSON" do
98
+ require "bigdecimal"
99
+
100
+ amount = BigDecimal("1.5")
101
+ state = build_state(data: { "amount" => amount })
102
+
103
+ expect(state.data).to eq("amount" => json_value(amount))
104
+ end
105
+
106
+ it "rejects metadata that cannot be serialized as JSON" do
107
+ cyclic = {}
108
+ cyclic["self"] = cyclic
109
+
110
+ expect {
111
+ build_state(data: cyclic)
112
+ }.to raise_error(JSON::JSONError)
113
+ end
114
+
115
+ it "rejects non-hash metadata" do
116
+ expect {
117
+ build_state(data: "nope")
118
+ }.to raise_error(ArgumentError, "data must be a Hash")
119
+ end
120
+
121
+ it "does not share nested data or strings with its clone" do
122
+ description = +"hello"
123
+ state = build_state(data: { "description" => description, "labels" => ["a"] })
124
+ clone = state.deep_clone
125
+
126
+ clone.users << "999"
127
+ clone.groups << "admins"
128
+ clone.data["labels"] << "b"
129
+ clone.data["description"] << "!"
130
+ description << "?"
131
+
132
+ expect(state.users).to eq %w[123 456]
133
+ expect(state.groups).to eq %w[employees supporters]
134
+ expect(state.data).to eq("description" => "hello", "labels" => ["a"])
135
+ expect(clone.data["description"]).to eq "hello!"
136
+ end
137
+
138
+ describe "Feature conversion" do
139
+ it "round-trips evaluation and metadata" do
140
+ rollout = Object.new
141
+ def rollout.active_in_group?(group, user)
142
+ group == :employees && user.id == 1
143
+ end
144
+
145
+ state = build_state(percentage: 20, users: ["42"], groups: ["employees"], data: { "description" => "New navigation" })
146
+ feature = feature_for(state, rollout: rollout)
147
+ restored = feature_for(feature.to_feature_state, rollout: rollout)
148
+
149
+ expect(feature.to_feature_state.name).to eq "chat"
150
+ expect(feature.to_feature_state.percentage).to eq 20.0
151
+ expect(feature.to_feature_state.users).to eq %w[42]
152
+ expect(feature.to_feature_state.groups).to eq %w[employees]
153
+ expect(feature.to_feature_state.data).to eq("description" => "New navigation")
154
+ expect(restored.name).to eq :chat
155
+
156
+ expect(restored.active?(double(id: 1))).to eq feature.active?(double(id: 1))
157
+ expect(restored.active?(double(id: 42))).to eq feature.active?(double(id: 42))
158
+ expect(restored.active?(double(id: 2))).to eq feature.active?(double(id: 2))
159
+ expect(restored.to_hash).to eq feature.to_hash
160
+ end
161
+
162
+ it "canonicalizes JSON-serializable metadata when converting a Feature" do
163
+ released_at = Time.utc(2026, 1, 1)
164
+ feature = feature_for(build_state)
165
+ feature.data["released_at"] = released_at
166
+ feature.data["kind"] = :beta
167
+
168
+ expect(feature.to_feature_state.data).to eq(
169
+ "description" => "New navigation",
170
+ "updated_at" => 1,
171
+ "released_at" => json_value(released_at),
172
+ "kind" => "beta",
173
+ )
174
+ end
175
+
176
+ it "does not share nested data with the source feature" do
177
+ feature = feature_for(build_state(data: { "labels" => ["a"] }))
178
+ state = feature.to_feature_state
179
+
180
+ feature.data["labels"] << "b"
181
+
182
+ expect(state.data).to eq("labels" => ["a"])
183
+ end
184
+
185
+ it "does not share nested data with a reconstructed feature" do
186
+ nested = ["a"]
187
+ state = build_state(data: { "labels" => nested })
188
+ feature = feature_for(state)
189
+
190
+ feature.data["labels"] << "b"
191
+ nested << "c"
192
+ state.data["labels"] << "d"
193
+
194
+ expect(feature.data).to eq("labels" => ["a", "b"])
195
+ expect(state.data).to eq("labels" => ["a", "d"])
196
+ end
197
+
198
+ it "uses sets when use_sets is enabled" do
199
+ feature = feature_for(build_state, options: { use_sets: true })
200
+
201
+ expect(feature.users).to eq %w[123 456].to_set
202
+ expect(feature.groups).to eq %i[employees supporters].to_set
203
+ end
204
+
205
+ it "preserves randomized percentage evaluation through a round trip" do
206
+ options = { randomize_percentage: true }
207
+ feature = feature_for(
208
+ Rollout::FeatureState.new(name: :chat, percentage: 20),
209
+ options: options,
210
+ )
211
+ restored = feature_for(feature.to_feature_state, options: options)
212
+
213
+ expect(restored.active?(double(id: 1))).to eq true
214
+ expect(restored.active?(double(id: 2))).to eq false
215
+ expect(restored.active?(double(id: 1))).to eq feature.active?(double(id: 1))
216
+ expect(restored.active?(double(id: 2))).to eq feature.active?(double(id: 2))
217
+ end
218
+ end
219
+ end
@@ -1,143 +1,114 @@
1
- require 'spec_helper'
2
-
3
- RSpec.describe 'Rollout::Logging' do
4
- let(:rollout) { Rollout.new($redis, logging: logging) }
5
- let(:logging) { true }
6
- let(:feature) { :foo }
7
-
8
- it 'logs changes' do
9
- expect(rollout.logging.last_event(feature)).to be_nil
10
-
11
- rollout.activate_percentage(feature, 50)
12
-
13
- expect(rollout.logging.updated_at(feature)).to_not be_nil
14
-
15
- first_event = rollout.logging.last_event(feature)
16
-
17
- expect(first_event.name).to eq 'update'
18
- expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
19
-
20
- rollout.activate_percentage(feature, 75)
21
-
22
- second_event = rollout.logging.last_event(feature)
23
-
24
- expect(second_event.name).to eq 'update'
25
- expect(second_event.data).to eq(before: { percentage: 50 }, after: { percentage: 75 })
26
-
27
- rollout.activate_group(feature, :hipsters)
1
+ require "spec_helper"
2
+
3
+ RSpec.describe "Rollout::Logging" do
4
+ def build_feature(percentage: 0, groups: [], users: [], data: {})
5
+ Rollout::Feature.new(
6
+ state: Rollout::FeatureState.new(
7
+ name: :foo,
8
+ percentage: percentage,
9
+ users: users,
10
+ groups: groups,
11
+ data: data,
12
+ ),
13
+ rollout: Object.new,
14
+ options: {},
15
+ )
16
+ end
28
17
 
29
- third_event = rollout.logging.last_event(feature)
18
+ let(:logger) { Rollout::Logging::Logger.new(adapter: Object.new) }
30
19
 
31
- expect(third_event.name).to eq 'update'
32
- expect(third_event.data).to eq(before: { groups: [] }, after: { groups: ['hipsters'] })
20
+ it "does not respond to logging unless enabled" do
21
+ rollout = Rollout.new(adapter: Object.new)
33
22
 
34
- expect(rollout.logging.events(feature)).to eq [first_event, second_event, third_event]
23
+ expect(rollout).not_to respond_to :logging
35
24
  end
36
25
 
37
- context 'logging data changes' do
38
- it 'logs changes' do
39
- expect(rollout.logging.last_event(feature)).to be_nil
40
-
41
- rollout.set_feature_data(feature, description: "foo")
42
26
 
43
- event = rollout.logging.last_event(feature)
27
+ it "builds an event for percentage changes" do
28
+ event = logger.event_for(build_feature, build_feature(percentage: 50))
44
29
 
45
- expect(event).not_to be_nil
46
- expect(event.name).to eq 'update'
47
- expect(event.data).to eq(before: { "data.description": nil }, after: { "data.description": "foo" })
48
- end
30
+ expect(event.name).to eq :update
31
+ expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 50 })
49
32
  end
50
33
 
51
- context 'no logging' do
52
- let(:logging) { nil }
34
+ it "builds an event for metadata changes" do
35
+ event = logger.event_for(
36
+ build_feature,
37
+ build_feature(data: { "description" => "foo" }),
38
+ )
53
39
 
54
- it 'doesnt even respond to logging' do
55
- expect(rollout).not_to respond_to :logging
56
- end
40
+ expect(event.data).to eq(before: { "data.description" => nil }, after: { "data.description" => "foo" })
57
41
  end
58
42
 
59
- context 'history truncation' do
60
- let(:logging) { { history_length: 1 } }
61
-
62
- it 'logs changes' do
63
- expect(rollout.logging.last_event(feature)).to be_nil
64
-
65
- rollout.activate_percentage(feature, 25)
66
-
67
- first_event = rollout.logging.last_event(feature)
68
-
69
- expect(first_event.name).to eq 'update'
70
- expect(first_event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
43
+ it "does not build an event when nothing changes" do
44
+ feature = build_feature(percentage: 25)
71
45
 
72
- rollout.activate_percentage(feature, 30)
73
-
74
- second_event = rollout.logging.last_event(feature)
75
-
76
- expect(second_event.name).to eq 'update'
77
- expect(second_event.data).to eq(before: { percentage: 25 }, after: { percentage: 30 })
78
-
79
- expect(rollout.logging.events(feature)).to eq [second_event]
80
- end
46
+ expect(logger.event_for(feature, feature)).to be_nil
81
47
  end
82
48
 
83
- context 'with context' do
84
- let(:current_user) { double(nickname: 'lester') }
85
-
86
- it 'adds context to the event' do
87
- rollout.logging.with_context(actor: current_user.nickname) do
88
- rollout.activate_percentage(feature, 25)
89
- end
90
-
91
- event = rollout.logging.last_event(feature)
92
-
93
- expect(event.name).to eq 'update'
94
- expect(event.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
95
- expect(event.context).to eq(actor: current_user.nickname)
49
+ it "adds context to the event" do
50
+ event = nil
51
+ logger.with_context(actor: "lester") do
52
+ event = logger.event_for(build_feature, build_feature(percentage: 25))
96
53
  end
54
+
55
+ expect(event.context).to eq(actor: "lester")
97
56
  end
98
57
 
99
- context 'global logs' do
100
- let(:logging) { { global: true } }
101
- let(:feature_foo) { 'foo' }
102
- let(:feature_bar) { 'bar' }
58
+ it "does not build an event when logging is disabled" do
59
+ event = logger.without do
60
+ logger.event_for(build_feature, build_feature(percentage: 25))
61
+ end
103
62
 
104
- it 'logs changes' do
105
- expect(rollout.logging.last_event(feature_foo)).to be_nil
63
+ expect(event).to be_nil
64
+ end
106
65
 
107
- rollout.activate_percentage(feature_foo, 25)
66
+ it "restores nested without state" do
67
+ nested_enabled = nil
68
+ outer_enabled = nil
108
69
 
109
- event_foo = rollout.logging.last_event(feature_foo)
70
+ logger.without do
71
+ logger.without {}
72
+ nested_enabled = logger.logging_enabled?
73
+ end
74
+ outer_enabled = logger.logging_enabled?
110
75
 
111
- expect(event_foo.feature).to eq feature_foo
112
- expect(event_foo.name).to eq 'update'
113
- expect(event_foo.data).to eq(before: { percentage: 0 }, after: { percentage: 25 })
76
+ expect(nested_enabled).to eq false
77
+ expect(outer_enabled).to eq true
78
+ end
114
79
 
115
- expect(rollout.logging.events(feature_foo)).to eq [event_foo]
80
+ it "forwards a history limit to the backend" do
81
+ backend = double("backend")
82
+ logger = Rollout::Logging::Logger.new(adapter: backend)
83
+ events = [Object.new]
116
84
 
117
- rollout.activate_percentage(feature_bar, 30)
85
+ expect(backend).to receive(:feature_events).with(:chat, limit: 2).and_return(events)
86
+ expect(logger.events(:chat, limit: 2)).to eq events
87
+ end
118
88
 
119
- event_bar = rollout.logging.last_event(feature_bar)
89
+ it "requests one event for last_event" do
90
+ backend = double("backend")
91
+ logger = Rollout::Logging::Logger.new(adapter: backend)
92
+ event = Object.new
120
93
 
121
- expect(event_bar.feature).to eq feature_bar
122
- expect(event_bar.name).to eq 'update'
123
- expect(event_bar.data).to eq(before: { percentage: 0 }, after: { percentage: 30 })
94
+ expect(backend).to receive(:feature_events).with(:chat, limit: 1).and_return([event])
95
+ expect(logger.last_event(:chat)).to eq event
96
+ end
124
97
 
125
- expect(rollout.logging.events(feature_bar)).to eq [event_bar]
98
+ it "forwards a global history limit to the backend" do
99
+ backend = double("backend")
100
+ logger = Rollout::Logging::Logger.new(adapter: backend)
101
+ events = [Object.new]
126
102
 
127
- expect(rollout.logging.global_events).to eq [event_foo, event_bar]
128
- end
103
+ expect(backend).to receive(:global_events).with(limit: 2).and_return(events)
104
+ expect(logger.global_events(limit: 2)).to eq events
129
105
  end
130
106
 
131
- context 'no logging for block' do
132
- it 'doesnt log' do
133
- rollout.logging.without do
134
- rollout.activate_percentage(feature, 25)
135
- end
136
-
137
- event = rollout.logging.last_event(feature)
107
+ it "delegates history deletion" do
108
+ backend = double("backend")
109
+ logger = Rollout::Logging::Logger.new(adapter: backend)
138
110
 
139
- expect(event).to be_nil
140
- end
111
+ expect(backend).to receive(:delete_feature_events).with(:chat)
112
+ logger.delete(:chat)
141
113
  end
142
114
  end
143
-
@@ -0,0 +1,8 @@
1
+ require "spec_helper"
2
+ require "support/backend_contract"
3
+
4
+ RSpec.describe RolloutMemoryBackend do
5
+ it_behaves_like "a rollout feature backend" do
6
+ let(:backend) { described_class.new }
7
+ end
8
+ end