flipper 1.3.4 → 1.4.2
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/.github/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +93 -0
- data/README.md +4 -3
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http.rb +37 -2
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +3 -2
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry.rb +1 -1
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +1 -1
- data/lib/flipper/poller.rb +49 -8
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +240 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/middleware_spec.rb +1 -1
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry_spec.rb +1 -1
- data/spec/flipper/engine_spec.rb +2 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/middleware/memoizer_spec.rb +37 -6
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +5 -0
- data/spec/spec_helper.rb +7 -0
- metadata +18 -112
- data/lib/flipper/expressions/duration.rb +0 -28
- data/spec/flipper/expressions/duration_spec.rb +0 -43
|
@@ -21,6 +21,12 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
21
21
|
expect { subject.get_multi([feature]) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
context "#add" do
|
|
26
|
+
it "raises an error for unknown feature" do
|
|
27
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
24
30
|
end
|
|
25
31
|
end
|
|
26
32
|
|
|
@@ -28,16 +34,22 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
28
34
|
subject { described_class.new(Flipper::Adapters::Memory.new, :warn) }
|
|
29
35
|
|
|
30
36
|
context "#get" do
|
|
31
|
-
it "
|
|
37
|
+
it "warns for unknown feature" do
|
|
32
38
|
expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
context "#get_multi" do
|
|
37
|
-
it "
|
|
43
|
+
it "warns for unknown feature" do
|
|
38
44
|
expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
|
|
39
45
|
end
|
|
40
46
|
end
|
|
47
|
+
|
|
48
|
+
context "#add" do
|
|
49
|
+
it "warns for unknown feature" do
|
|
50
|
+
expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
context "handler = Block" do
|
|
@@ -48,17 +60,63 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
context "#get" do
|
|
51
|
-
it "
|
|
63
|
+
it "calls block for unknown feature" do
|
|
52
64
|
subject.get(feature)
|
|
53
65
|
expect(unknown_features).to eq(["unknown"])
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
context "#get_multi" do
|
|
58
|
-
it "
|
|
70
|
+
it "calls block for unknown feature" do
|
|
59
71
|
subject.get_multi([flipper[:foo], flipper[:bar]])
|
|
60
72
|
expect(unknown_features).to eq(["foo", "bar"])
|
|
61
73
|
end
|
|
62
74
|
end
|
|
75
|
+
|
|
76
|
+
context "#add" do
|
|
77
|
+
it "calls block for unknown feature" do
|
|
78
|
+
subject.add(feature)
|
|
79
|
+
expect(unknown_features).to eq(["unknown"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe ".with_sync_mode" do
|
|
85
|
+
subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
|
|
86
|
+
|
|
87
|
+
it "bypasses strict checks for add" do
|
|
88
|
+
described_class.with_sync_mode do
|
|
89
|
+
expect { subject.add(feature) }.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "bypasses strict checks for get" do
|
|
94
|
+
described_class.with_sync_mode do
|
|
95
|
+
expect { subject.get(feature) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "bypasses strict checks for get_multi" do
|
|
100
|
+
described_class.with_sync_mode do
|
|
101
|
+
expect { subject.get_multi([feature]) }.not_to raise_error
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "restores previous sync mode after block" do
|
|
106
|
+
described_class.with_sync_mode do
|
|
107
|
+
# inside sync mode
|
|
108
|
+
end
|
|
109
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "restores previous sync mode even on error" do
|
|
113
|
+
begin
|
|
114
|
+
described_class.with_sync_mode do
|
|
115
|
+
raise "boom"
|
|
116
|
+
end
|
|
117
|
+
rescue RuntimeError
|
|
118
|
+
end
|
|
119
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
120
|
+
end
|
|
63
121
|
end
|
|
64
122
|
end
|
|
@@ -105,6 +105,18 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
|
|
|
105
105
|
expect_no_enable_or_disable
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
it "updates expression when remote conditionally enabled but expression is nil" do
|
|
109
|
+
remote = Flipper::GateValues.new(expression: nil, actors: Set["1"])
|
|
110
|
+
feature.enable_expression(plan_expression)
|
|
111
|
+
feature.enable_actor(Flipper::Actor.new("1"))
|
|
112
|
+
adapter.reset
|
|
113
|
+
|
|
114
|
+
described_class.new(feature, feature.gate_values, remote).call
|
|
115
|
+
|
|
116
|
+
expect(feature.expression_value).to eq(nil)
|
|
117
|
+
expect_only_disable
|
|
118
|
+
end
|
|
119
|
+
|
|
108
120
|
it "adds remotely added actors" do
|
|
109
121
|
remote = Flipper::GateValues.new(actors: Set["1", "2"])
|
|
110
122
|
feature.enable_actor(Flipper::Actor.new("1"))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "flipper/adapters/memory"
|
|
2
|
+
require "flipper/adapters/actor_limit"
|
|
2
3
|
require "flipper/instrumenters/memory"
|
|
3
4
|
require "flipper/adapters/sync/synchronizer"
|
|
4
5
|
|
|
@@ -84,5 +85,91 @@ RSpec.describe Flipper::Adapters::Sync::Synchronizer do
|
|
|
84
85
|
|
|
85
86
|
expect(local_flipper.features.map(&:key)).to eq([])
|
|
86
87
|
end
|
|
88
|
+
|
|
89
|
+
it 'emits feature_operation.flipper events when syncing' do
|
|
90
|
+
remote_flipper.enable(:search)
|
|
91
|
+
|
|
92
|
+
subject.call
|
|
93
|
+
|
|
94
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
95
|
+
enable_events = events.select { |e| e.payload[:operation] == :enable }
|
|
96
|
+
expect(enable_events).not_to be_empty
|
|
97
|
+
|
|
98
|
+
feature_names = enable_events.map { |e| e.payload[:feature_name].to_s }
|
|
99
|
+
expect(feature_names).to include("search")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'emits feature_operation.flipper events when adding features' do
|
|
103
|
+
remote_flipper.add(:new_feature)
|
|
104
|
+
|
|
105
|
+
subject.call
|
|
106
|
+
|
|
107
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
108
|
+
add_events = events.select { |e| e.payload[:operation] == :add }
|
|
109
|
+
expect(add_events).not_to be_empty
|
|
110
|
+
|
|
111
|
+
feature_names = add_events.map { |e| e.payload[:feature_name].to_s }
|
|
112
|
+
expect(feature_names).to include("new_feature")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'emits feature_operation.flipper events when removing features' do
|
|
116
|
+
local_flipper.add(:old_feature)
|
|
117
|
+
|
|
118
|
+
subject.call
|
|
119
|
+
|
|
120
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
121
|
+
remove_events = events.select { |e| e.payload[:operation] == :remove }
|
|
122
|
+
expect(remove_events).not_to be_empty
|
|
123
|
+
|
|
124
|
+
feature_names = remove_events.map { |e| e.payload[:feature_name].to_s }
|
|
125
|
+
expect(feature_names).to include("old_feature")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
context 'with ActorLimit adapter wrapping local' do
|
|
130
|
+
let(:limit) { 10 }
|
|
131
|
+
let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) }
|
|
132
|
+
let(:limited_local_flipper) { Flipper.new(limited_local) }
|
|
133
|
+
|
|
134
|
+
subject { described_class.new(limited_local, remote, instrumenter: instrumenter) }
|
|
135
|
+
|
|
136
|
+
it 'syncs actors even when remote has more actors than local limit' do
|
|
137
|
+
# Remote has more actors than local limit allows
|
|
138
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
139
|
+
|
|
140
|
+
# This should NOT raise - sync should bypass actor limits
|
|
141
|
+
expect { subject.call }.not_to raise_error
|
|
142
|
+
|
|
143
|
+
# All actors should be synced
|
|
144
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'syncs new actors added to remote after initial sync' do
|
|
148
|
+
# Initial state: remote has 20 actors, local limit is 10
|
|
149
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
150
|
+
|
|
151
|
+
# First sync - should work despite exceeding limit
|
|
152
|
+
subject.call
|
|
153
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
154
|
+
|
|
155
|
+
# Add a 21st actor to remote (simulating Cloud adding a new actor)
|
|
156
|
+
remote_flipper[:search].enable_actor Flipper::Actor.new("User;20")
|
|
157
|
+
|
|
158
|
+
# Sync again - should pick up the new actor
|
|
159
|
+
expect { subject.call }.not_to raise_error
|
|
160
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(21)
|
|
161
|
+
expect(limited_local_flipper[:search].actors_value).to include("User;20")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'still enforces limit for direct enable operations' do
|
|
165
|
+
# First sync 20 actors from remote
|
|
166
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
167
|
+
subject.call
|
|
168
|
+
|
|
169
|
+
# Direct enable should still fail because we're over limit
|
|
170
|
+
expect {
|
|
171
|
+
limited_local_flipper[:search].enable_actor Flipper::Actor.new("User;new")
|
|
172
|
+
}.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
|
|
173
|
+
end
|
|
87
174
|
end
|
|
88
175
|
end
|
|
@@ -197,4 +197,17 @@ RSpec.describe Flipper::Adapters::Sync do
|
|
|
197
197
|
expect(remote_adapter).to receive(:get_all).and_raise(exception)
|
|
198
198
|
expect { subject.get_all }.not_to raise_error
|
|
199
199
|
end
|
|
200
|
+
|
|
201
|
+
describe '#adapter_stack' do
|
|
202
|
+
it 'returns the tree representation' do
|
|
203
|
+
expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'shows nested adapters in the tree' do
|
|
207
|
+
memory = Flipper::Adapters::Memory.new
|
|
208
|
+
strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
|
|
209
|
+
adapter = described_class.new(memory, strict, interval: 1)
|
|
210
|
+
expect(adapter.adapter_stack).to eq("sync(local: memory, remote: strict -> memory)")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
200
213
|
end
|
data/spec/flipper/cli_spec.rb
CHANGED
|
@@ -147,6 +147,57 @@ RSpec.describe Flipper::CLI do
|
|
|
147
147
|
it { should have_attributes(status: 1, stderr: /invalid option: --nope/) }
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
describe "export" do
|
|
151
|
+
before do
|
|
152
|
+
Flipper.enable :search
|
|
153
|
+
Flipper.disable :analytics
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "outputs valid JSON export" do
|
|
157
|
+
expect(subject).to have_attributes(status: 0)
|
|
158
|
+
data = JSON.parse(subject.stdout)
|
|
159
|
+
expect(data["version"]).to eq(1)
|
|
160
|
+
expect(data["features"]).to have_key("search")
|
|
161
|
+
expect(data["features"]).to have_key("analytics")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe "cloud" do
|
|
166
|
+
it "shows help when no subcommand given" do
|
|
167
|
+
expect(subject).to have_attributes(status: 0, stdout: /migrate/)
|
|
168
|
+
expect(subject.stdout).to match(/push/)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe "cloud migrate" do
|
|
173
|
+
before do
|
|
174
|
+
Flipper.enable :search
|
|
175
|
+
require 'flipper/cloud/migrate'
|
|
176
|
+
allow(Flipper::Cloud).to receive(:migrate).and_return(
|
|
177
|
+
Flipper::Cloud::MigrateResult.new(code: 200, url: "https://www.flippercloud.io/cloud/setup/abc123")
|
|
178
|
+
)
|
|
179
|
+
allow(cli).to receive(:system)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "prints the cloud URL" do
|
|
183
|
+
expect(subject).to have_attributes(status: 0, stdout: /flippercloud\.io/)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "cloud push test-token" do
|
|
188
|
+
before do
|
|
189
|
+
Flipper.enable :search
|
|
190
|
+
require 'flipper/cloud/migrate'
|
|
191
|
+
allow(Flipper::Cloud).to receive(:push).and_return(
|
|
192
|
+
Flipper::Cloud::MigrateResult.new(code: 204, url: nil)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "prints success message" do
|
|
197
|
+
expect(subject).to have_attributes(status: 0, stdout: /Successfully pushed/)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
150
201
|
describe "show foo" do
|
|
151
202
|
context "boolean" do
|
|
152
203
|
before { Flipper.enable :foo }
|
|
@@ -274,7 +274,7 @@ RSpec.describe Flipper::Cloud::Middleware do
|
|
|
274
274
|
private
|
|
275
275
|
|
|
276
276
|
def stub_request_for_token(token, status: 200)
|
|
277
|
-
stub = stub_request(:get,
|
|
277
|
+
stub = stub_request(:get, %r{\Ahttps://www\.flippercloud\.io/adapter/features\?(?=.*\bexclude_gate_names=true\b)(?=.*\b_cb=\d+\b)}).
|
|
278
278
|
with({
|
|
279
279
|
headers: {
|
|
280
280
|
'flipper-cloud-token' => token,
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
require "flipper/cloud/migrate"
|
|
2
|
+
require "flipper/typecast"
|
|
3
|
+
require "webmock/rspec"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Flipper::Cloud, ".migrate" do
|
|
6
|
+
let(:flipper) { Flipper.new(Flipper::Adapters::Memory.new) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
flipper.enable :search
|
|
10
|
+
flipper.disable :analytics
|
|
11
|
+
flipper.enable_percentage_of_actors :checkout, 50
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
around do |example|
|
|
15
|
+
original = ENV["FLIPPER_CLOUD_URL"]
|
|
16
|
+
ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555"
|
|
17
|
+
example.run
|
|
18
|
+
ensure
|
|
19
|
+
ENV["FLIPPER_CLOUD_URL"] = original
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decompress_request_body
|
|
23
|
+
raw = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last.body
|
|
24
|
+
JSON.parse(Flipper::Typecast.from_gzip(raw))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe ".migrate" do
|
|
28
|
+
it "returns a MigrateResult with code and url on success" do
|
|
29
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
30
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"})
|
|
31
|
+
|
|
32
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
33
|
+
|
|
34
|
+
expect(result).to be_a(Flipper::Cloud::MigrateResult)
|
|
35
|
+
expect(result.code).to eq(200)
|
|
36
|
+
expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "sends export data and metadata in the request body" do
|
|
40
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
41
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}')
|
|
42
|
+
|
|
43
|
+
Flipper::Cloud.migrate(flipper, app_name: "MyApp")
|
|
44
|
+
|
|
45
|
+
expect(stub).to have_been_requested
|
|
46
|
+
body = decompress_request_body
|
|
47
|
+
expect(body["metadata"]["app_name"]).to eq("MyApp")
|
|
48
|
+
expect(body["export"]["version"]).to eq(1)
|
|
49
|
+
expect(body["export"]["features"]).to have_key("search")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "sends gzip-compressed request body" do
|
|
53
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
54
|
+
.with(headers: {"content-encoding" => "gzip"})
|
|
55
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
56
|
+
|
|
57
|
+
Flipper::Cloud.migrate(flipper)
|
|
58
|
+
|
|
59
|
+
expect(stub).to have_been_requested
|
|
60
|
+
body = decompress_request_body
|
|
61
|
+
expect(body["export"]["features"]).to have_key("search")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "handles error responses" do
|
|
65
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
66
|
+
.to_return(status: 500, body: '{"error":"Internal Server Error"}')
|
|
67
|
+
|
|
68
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
69
|
+
|
|
70
|
+
expect(result.code).to eq(500)
|
|
71
|
+
expect(result.url).to be_nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "includes error message from response body" do
|
|
75
|
+
stub_request(:post, "http://localhost:5555/api/migrate")
|
|
76
|
+
.to_return(status: 422, body: '{"error":"Invalid export format"}')
|
|
77
|
+
|
|
78
|
+
result = Flipper::Cloud.migrate(flipper)
|
|
79
|
+
|
|
80
|
+
expect(result.code).to eq(422)
|
|
81
|
+
expect(result.message).to eq("Invalid export format")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it "uses FLIPPER_CLOUD_URL environment variable" do
|
|
85
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
86
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
87
|
+
|
|
88
|
+
Flipper::Cloud.migrate(flipper)
|
|
89
|
+
|
|
90
|
+
expect(stub).to have_been_requested
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "sends content-type and accept headers" do
|
|
94
|
+
stub = stub_request(:post, "http://localhost:5555/api/migrate")
|
|
95
|
+
.with(headers: {
|
|
96
|
+
"content-type" => "application/json",
|
|
97
|
+
"accept" => "application/json",
|
|
98
|
+
})
|
|
99
|
+
.to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
|
|
100
|
+
|
|
101
|
+
Flipper::Cloud.migrate(flipper)
|
|
102
|
+
|
|
103
|
+
expect(stub).to have_been_requested
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
describe ".push" do
|
|
108
|
+
it "returns a MigrateResult with code on success" do
|
|
109
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
110
|
+
.to_return(status: 204, body: "")
|
|
111
|
+
|
|
112
|
+
result = Flipper::Cloud.push("test-token", flipper)
|
|
113
|
+
|
|
114
|
+
expect(result).to be_a(Flipper::Cloud::MigrateResult)
|
|
115
|
+
expect(result.code).to eq(204)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "sends the token as a header" do
|
|
119
|
+
stub = stub_request(:post, "http://localhost:5555/adapter/import")
|
|
120
|
+
.with(headers: {"flipper-cloud-token" => "test-token"})
|
|
121
|
+
.to_return(status: 204, body: "")
|
|
122
|
+
|
|
123
|
+
Flipper::Cloud.push("test-token", flipper)
|
|
124
|
+
|
|
125
|
+
expect(stub).to have_been_requested
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it "sends gzip-compressed export contents as the body" do
|
|
129
|
+
stub = stub_request(:post, "http://localhost:5555/adapter/import")
|
|
130
|
+
.with(headers: {"content-encoding" => "gzip"})
|
|
131
|
+
.to_return(status: 204, body: "")
|
|
132
|
+
|
|
133
|
+
Flipper::Cloud.push("test-token", flipper)
|
|
134
|
+
|
|
135
|
+
expect(stub).to have_been_requested
|
|
136
|
+
body = decompress_request_body
|
|
137
|
+
expect(body["version"]).to eq(1)
|
|
138
|
+
expect(body["features"]).to have_key("search")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it "handles error responses" do
|
|
142
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
143
|
+
.to_return(status: 401, body: '{"error":"Unauthorized"}')
|
|
144
|
+
|
|
145
|
+
result = Flipper::Cloud.push("bad-token", flipper)
|
|
146
|
+
|
|
147
|
+
expect(result.code).to eq(401)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "includes error message from response body" do
|
|
151
|
+
stub_request(:post, "http://localhost:5555/adapter/import")
|
|
152
|
+
.to_return(status: 401, body: '{"error":"Invalid token"}')
|
|
153
|
+
|
|
154
|
+
result = Flipper::Cloud.push("bad-token", flipper)
|
|
155
|
+
|
|
156
|
+
expect(result.code).to eq(401)
|
|
157
|
+
expect(result.message).to eq("Invalid token")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -122,7 +122,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
|
|
|
122
122
|
# Check the conig interval and the timer interval.
|
|
123
123
|
expect(telemetry.interval).to eq(120)
|
|
124
124
|
expect(telemetry.timer.execution_interval).to eq(120)
|
|
125
|
-
expect(stub).to have_been_requested.
|
|
125
|
+
expect(stub).to have_been_requested.at_least_times(5)
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
it "doesn't try to update telemetry interval from error if not response error" do
|
data/spec/flipper/engine_spec.rb
CHANGED
|
@@ -263,10 +263,10 @@ RSpec.describe Flipper::Engine do
|
|
|
263
263
|
Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
it "configures webhook app" do
|
|
266
|
+
it "configures webhook app and uses cache busting" do
|
|
267
267
|
silence { application.initialize! }
|
|
268
268
|
|
|
269
|
-
stub = stub_request(:get,
|
|
269
|
+
stub = stub_request(:get, /https:\/\/www\.flippercloud\.io\/adapter\/features\?_cb=\d+&exclude_gate_names=true/).with({
|
|
270
270
|
headers: { "flipper-cloud-token" => ENV["FLIPPER_CLOUD_TOKEN"] },
|
|
271
271
|
}).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {})
|
|
272
272
|
|
|
@@ -9,5 +9,21 @@ RSpec.describe Flipper::Expressions::Time do
|
|
|
9
9
|
it "returns time for #iso8601 format" do
|
|
10
10
|
expect(described_class.call(time.iso8601)).to eq(time)
|
|
11
11
|
end
|
|
12
|
+
|
|
13
|
+
it "returns time for epoch integer" do
|
|
14
|
+
expect(described_class.call(time.to_i)).to eq(Time.at(time.to_i).utc)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "returns time for epoch float" do
|
|
18
|
+
expect(described_class.call(time.to_f)).to be_within(0.001).of(time)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "returns utc for string input" do
|
|
22
|
+
expect(described_class.call(time.to_s).utc?).to be(true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "returns utc for numeric input" do
|
|
26
|
+
expect(described_class.call(time.to_i).utc?).to be(true)
|
|
27
|
+
end
|
|
12
28
|
end
|
|
13
29
|
end
|
|
@@ -62,6 +62,30 @@ RSpec.describe Flipper::Gates::Expression do
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
context 'for actor in context' do
|
|
66
|
+
it 'passes actor to expression context' do
|
|
67
|
+
actor = Flipper::Actor.new("User;1", {type: "User"})
|
|
68
|
+
wrapped_actor = Flipper::Types::Actor.new(actor)
|
|
69
|
+
expression = Flipper.property(:flipper_id).eq("User;1")
|
|
70
|
+
ctx = Flipper::FeatureCheckContext.new(
|
|
71
|
+
feature_name: feature_name,
|
|
72
|
+
values: Flipper::GateValues.new(expression: expression.value),
|
|
73
|
+
actors: [wrapped_actor]
|
|
74
|
+
)
|
|
75
|
+
expect(subject.open?(ctx)).to be(true)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'passes nil actor when no actors provided' do
|
|
79
|
+
expression = Flipper.boolean(true).eq(true)
|
|
80
|
+
ctx = Flipper::FeatureCheckContext.new(
|
|
81
|
+
feature_name: feature_name,
|
|
82
|
+
values: Flipper::GateValues.new(expression: expression.value),
|
|
83
|
+
actors: nil
|
|
84
|
+
)
|
|
85
|
+
expect(subject.open?(ctx)).to be(true)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
65
89
|
context 'for properties that have symbol keys' do
|
|
66
90
|
it 'returns true when expression evalutes to true' do
|
|
67
91
|
expression = Flipper.property(:type).eq("User")
|
|
@@ -75,6 +99,64 @@ RSpec.describe Flipper::Gates::Expression do
|
|
|
75
99
|
expect(subject.open?(context)).to be(false)
|
|
76
100
|
end
|
|
77
101
|
end
|
|
102
|
+
|
|
103
|
+
context 'for time-based expressions' do
|
|
104
|
+
it 'enables when now is past a scheduled epoch' do
|
|
105
|
+
past_epoch = Time.now.to_i - 86_400
|
|
106
|
+
expression = Flipper.now.gte(Flipper.time(past_epoch))
|
|
107
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'does not enable when now is before a future epoch' do
|
|
111
|
+
future_epoch = Time.now.to_i + 86_400
|
|
112
|
+
expression = Flipper.now.gte(Flipper.time(future_epoch))
|
|
113
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'enables when now is past a scheduled datetime' do
|
|
117
|
+
past_time = (Time.now.utc - 86_400).iso8601
|
|
118
|
+
expression = Flipper.now.gte(Flipper.time(past_time))
|
|
119
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'does not enable when now is before a future datetime' do
|
|
123
|
+
future_time = (Time.now.utc + 86_400).iso8601
|
|
124
|
+
expression = Flipper.now.gte(Flipper.time(future_time))
|
|
125
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'enables expiring features with lt' do
|
|
129
|
+
future_time = (Time.now.utc + 86_400).iso8601
|
|
130
|
+
expression = Flipper.now.lt(Flipper.time(future_time))
|
|
131
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'disables expired features with lt' do
|
|
135
|
+
past_time = (Time.now.utc - 86_400).iso8601
|
|
136
|
+
expression = Flipper.now.lt(Flipper.time(past_time))
|
|
137
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'enables within a time window using all' do
|
|
141
|
+
start_time = (Time.now.utc - 86_400).iso8601
|
|
142
|
+
end_time = (Time.now.utc + 86_400).iso8601
|
|
143
|
+
expression = Flipper.all(
|
|
144
|
+
Flipper.now.gte(Flipper.time(start_time)),
|
|
145
|
+
Flipper.now.lt(Flipper.time(end_time))
|
|
146
|
+
)
|
|
147
|
+
expect(subject.open?(context(expression.value))).to be(true)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'does not enable outside a time window' do
|
|
151
|
+
start_time = (Time.now.utc + 86_400).iso8601
|
|
152
|
+
end_time = (Time.now.utc + 172_800).iso8601
|
|
153
|
+
expression = Flipper.all(
|
|
154
|
+
Flipper.now.gte(Flipper.time(start_time)),
|
|
155
|
+
Flipper.now.lt(Flipper.time(end_time))
|
|
156
|
+
)
|
|
157
|
+
expect(subject.open?(context(expression.value))).to be(false)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
78
160
|
end
|
|
79
161
|
|
|
80
162
|
describe '#protects?' do
|
|
@@ -2,6 +2,8 @@ require 'rack/test'
|
|
|
2
2
|
require 'active_support/cache'
|
|
3
3
|
require 'flipper/adapters/active_support_cache_store'
|
|
4
4
|
require 'flipper/adapters/operation_logger'
|
|
5
|
+
require 'flipper/adapters/actor_limit'
|
|
6
|
+
require 'flipper/adapters/sync'
|
|
5
7
|
|
|
6
8
|
RSpec.describe Flipper::Middleware::Memoizer do
|
|
7
9
|
include Rack::Test::Methods
|
|
@@ -470,18 +472,47 @@ RSpec.describe Flipper::Middleware::Memoizer do
|
|
|
470
472
|
|
|
471
473
|
get '/', {}, 'flipper' => flipper
|
|
472
474
|
expect(logged_cached.count(:get_all)).to be(1)
|
|
473
|
-
expect(logged_memory.count(:
|
|
474
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
475
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
475
476
|
|
|
476
477
|
get '/', {}, 'flipper' => flipper
|
|
477
478
|
expect(logged_cached.count(:get_all)).to be(2)
|
|
478
|
-
expect(logged_memory.count(:
|
|
479
|
-
expect(logged_memory.count(:get_multi)).to be(1)
|
|
479
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
480
480
|
|
|
481
481
|
get '/', {}, 'flipper' => flipper
|
|
482
482
|
expect(logged_cached.count(:get_all)).to be(3)
|
|
483
|
-
expect(logged_memory.count(:
|
|
484
|
-
|
|
483
|
+
expect(logged_memory.count(:get_all)).to be(1)
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
context 'with preload:true and Sync adapter wrapped with ActorLimit' do
|
|
488
|
+
it 'preloads even when remote has more actors than local limit' do
|
|
489
|
+
local = Flipper::Adapters::Memory.new
|
|
490
|
+
remote = Flipper::Adapters::Memory.new
|
|
491
|
+
remote_flipper = Flipper.new(remote)
|
|
492
|
+
|
|
493
|
+
# Remote has more actors than limit allows (actor-only enables, not boolean)
|
|
494
|
+
10.times { |i| remote_flipper[:stats].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
495
|
+
|
|
496
|
+
# Sync adapter will sync from remote to local, then ActorLimit wraps it
|
|
497
|
+
# Use interval: 0 to force sync on every call
|
|
498
|
+
sync_adapter = Flipper::Adapters::Sync.new(local, remote, interval: 0)
|
|
499
|
+
limited_adapter = Flipper::Adapters::ActorLimit.new(sync_adapter, 5)
|
|
500
|
+
test_flipper = Flipper.new(limited_adapter)
|
|
501
|
+
|
|
502
|
+
app = lambda do |env|
|
|
503
|
+
f = env['flipper']
|
|
504
|
+
f[:stats].enabled?
|
|
505
|
+
[200, {}, []]
|
|
506
|
+
end
|
|
507
|
+
middleware = described_class.new(app, preload: true)
|
|
508
|
+
|
|
509
|
+
# Preload should work without raising ActorLimit::LimitExceeded
|
|
510
|
+
expect {
|
|
511
|
+
middleware.call('flipper' => test_flipper)
|
|
512
|
+
}.not_to raise_error
|
|
513
|
+
|
|
514
|
+
# Verify actors were synced (all 10, not just 5)
|
|
515
|
+
expect(test_flipper[:stats].actors_value.size).to eq(10)
|
|
485
516
|
end
|
|
486
517
|
end
|
|
487
518
|
end
|