flipper 1.3.6 → 1.4.1

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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -6
  3. data/.github/workflows/examples.yml +5 -4
  4. data/.github/workflows/release.yml +54 -0
  5. data/.superset/config.json +4 -0
  6. data/CLAUDE.md +22 -3
  7. data/README.md +4 -3
  8. data/examples/cloud/poll_interval/README.md +111 -0
  9. data/examples/cloud/poll_interval/client.rb +108 -0
  10. data/examples/cloud/poll_interval/server.rb +98 -0
  11. data/examples/expressions.rb +35 -11
  12. data/lib/flipper/adapter.rb +17 -1
  13. data/lib/flipper/adapters/actor_limit.rb +27 -1
  14. data/lib/flipper/adapters/cache_base.rb +21 -3
  15. data/lib/flipper/adapters/dual_write.rb +6 -2
  16. data/lib/flipper/adapters/failover.rb +9 -3
  17. data/lib/flipper/adapters/failsafe.rb +2 -2
  18. data/lib/flipper/adapters/http/client.rb +15 -4
  19. data/lib/flipper/adapters/http.rb +37 -2
  20. data/lib/flipper/adapters/instrumented.rb +2 -2
  21. data/lib/flipper/adapters/memoizable.rb +3 -3
  22. data/lib/flipper/adapters/memory.rb +1 -1
  23. data/lib/flipper/adapters/pstore.rb +1 -1
  24. data/lib/flipper/adapters/strict.rb +30 -0
  25. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  26. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  27. data/lib/flipper/adapters/sync.rb +7 -3
  28. data/lib/flipper/cli.rb +51 -0
  29. data/lib/flipper/cloud/configuration.rb +9 -4
  30. data/lib/flipper/cloud/dsl.rb +2 -2
  31. data/lib/flipper/cloud/middleware.rb +1 -1
  32. data/lib/flipper/cloud/migrate.rb +71 -0
  33. data/lib/flipper/cloud/telemetry.rb +1 -1
  34. data/lib/flipper/cloud.rb +1 -0
  35. data/lib/flipper/dsl.rb +1 -1
  36. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  37. data/lib/flipper/expressions/time.rb +8 -1
  38. data/lib/flipper/gates/expression.rb +2 -2
  39. data/lib/flipper/poller.rb +52 -9
  40. data/lib/flipper/version.rb +1 -1
  41. data/lib/flipper.rb +17 -1
  42. data/spec/flipper/adapter_spec.rb +20 -0
  43. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  44. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  45. data/spec/flipper/adapters/failover_spec.rb +12 -0
  46. data/spec/flipper/adapters/http_spec.rb +240 -0
  47. data/spec/flipper/adapters/strict_spec.rb +62 -4
  48. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  49. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  50. data/spec/flipper/adapters/sync_spec.rb +13 -0
  51. data/spec/flipper/cli_spec.rb +51 -0
  52. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  53. data/spec/flipper/cloud/dsl_spec.rb +10 -2
  54. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  55. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  56. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  57. data/spec/flipper/engine_spec.rb +2 -2
  58. data/spec/flipper/expressions/time_spec.rb +16 -0
  59. data/spec/flipper/gates/expression_spec.rb +82 -0
  60. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  61. data/spec/flipper/poller_spec.rb +347 -4
  62. data/spec/flipper_integration_spec.rb +133 -0
  63. data/spec/flipper_spec.rb +6 -1
  64. data/spec/spec_helper.rb +7 -0
  65. metadata +17 -112
  66. data/lib/flipper/expressions/duration.rb +0 -28
  67. 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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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 "raises an error for unknown feature" do
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
@@ -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 }
@@ -135,6 +135,9 @@ RSpec.describe Flipper::Cloud::Configuration do
135
135
  end
136
136
 
137
137
  it "sets sync_method to :webhook if sync_secret provided" do
138
+ # The initial sync of http to local invokes this web request.
139
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
140
+
138
141
  instance = described_class.new(required_options.merge({
139
142
  sync_secret: "secret",
140
143
  }))
@@ -144,6 +147,9 @@ RSpec.describe Flipper::Cloud::Configuration do
144
147
  end
145
148
 
146
149
  it "sets sync_method to :webhook if FLIPPER_CLOUD_SYNC_SECRET set" do
150
+ # The initial sync of http to local invokes this web request.
151
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
152
+
147
153
  ENV["FLIPPER_CLOUD_SYNC_SECRET"] = "abc"
148
154
  instance = described_class.new(required_options)
149
155
 
@@ -5,6 +5,9 @@ require 'flipper/adapters/instrumented'
5
5
 
6
6
  RSpec.describe Flipper::Cloud::DSL do
7
7
  it 'delegates everything to flipper instance' do
8
+ # stub the initial sync of http to local
9
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
10
+
8
11
  cloud_configuration = Flipper::Cloud::Configuration.new({
9
12
  token: "asdf",
10
13
  sync_secret: "tasty",
@@ -27,10 +30,13 @@ RSpec.describe Flipper::Cloud::DSL do
27
30
  })
28
31
  dsl = described_class.new(cloud_configuration)
29
32
  dsl.sync
30
- expect(stub).to have_been_requested
33
+ expect(stub).to have_been_requested.at_least_once
31
34
  end
32
35
 
33
36
  it 'delegates sync_secret to cloud configuration' do
37
+ # stub the initial sync of http to local
38
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
39
+
34
40
  cloud_configuration = Flipper::Cloud::Configuration.new({
35
41
  token: "asdf",
36
42
  sync_secret: "tasty",
@@ -53,13 +59,15 @@ RSpec.describe Flipper::Cloud::DSL do
53
59
  end
54
60
 
55
61
  subject do
62
+ # stub the initial sync of http to local
63
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
56
64
  described_class.new(cloud_configuration)
57
65
  end
58
66
 
59
67
  it "sends reads to local adapter" do
60
68
  subject.features
61
69
  subject.enabled?(:foo)
62
- expect(local_adapter.count(:features)).to be(1)
70
+ expect(local_adapter.count(:features)).to be(2)
63
71
  expect(local_adapter.count(:get)).to be(1)
64
72
  end
65
73
 
@@ -62,7 +62,7 @@ RSpec.describe Flipper::Cloud::Middleware do
62
62
  {"name" => "premium"},
63
63
  ],
64
64
  })
65
- expect(stub).to have_been_requested
65
+ expect(stub).to have_been_made.at_least_once
66
66
  end
67
67
  end
68
68
 
@@ -72,15 +72,17 @@ RSpec.describe Flipper::Cloud::Middleware do
72
72
  Flipper::Cloud::MessageVerifier.new(secret: "nope").generate(request_body, timestamp)
73
73
  }
74
74
 
75
- it 'uses instance to sync' do
76
- stub = stub_request_for_token('regular')
75
+ it 'does not perform webhook sync' do
76
+ webhook_regular_stub = stub_request_for_token('regular', from_webhook: true)
77
+ poll_regular_stub = stub_request_for_token('regular', from_webhook: false)
77
78
  env = {
78
79
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
79
80
  }
80
81
  post '/', request_body, env
81
82
 
82
83
  expect(last_response.status).to eq(400)
83
- expect(stub).not_to have_been_requested
84
+ expect(poll_regular_stub).to have_been_requested.at_least_once
85
+ expect(webhook_regular_stub).not_to have_been_requested
84
86
  end
85
87
  end
86
88
 
@@ -103,7 +105,7 @@ RSpec.describe Flipper::Cloud::Middleware do
103
105
  expect(last_response.status).to eq(402)
104
106
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
105
107
  expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 402")
106
- expect(stub).to have_been_requested
108
+ expect(stub).to have_been_made.at_least_once
107
109
  end
108
110
  end
109
111
 
@@ -126,7 +128,7 @@ RSpec.describe Flipper::Cloud::Middleware do
126
128
  expect(last_response.status).to eq(500)
127
129
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Flipper::Adapters::Http::Error")
128
130
  expect(last_response.headers["flipper-cloud-response-error-message"]).to include("Failed with status: 503")
129
- expect(stub).to have_been_requested
131
+ expect(stub).to have_been_made.at_least_once
130
132
  end
131
133
  end
132
134
 
@@ -149,7 +151,7 @@ RSpec.describe Flipper::Cloud::Middleware do
149
151
  expect(last_response.status).to eq(500)
150
152
  expect(last_response.headers["flipper-cloud-response-error-class"]).to eq("Net::OpenTimeout")
151
153
  expect(last_response.headers["flipper-cloud-response-error-message"]).to eq("execution expired")
152
- expect(stub).to have_been_requested
154
+ expect(stub).to have_been_made.at_least_once
153
155
  end
154
156
  end
155
157
 
@@ -160,7 +162,8 @@ RSpec.describe Flipper::Cloud::Middleware do
160
162
  }
161
163
 
162
164
  it 'uses env instance to sync' do
163
- stub = stub_request_for_token('env')
165
+ regular_stub = stub_request_for_token('regular')
166
+ env_stub = stub_request_for_token('env')
164
167
  env = {
165
168
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
166
169
  'flipper' => env_flipper,
@@ -168,7 +171,8 @@ RSpec.describe Flipper::Cloud::Middleware do
168
171
  post '/', request_body, env
169
172
 
170
173
  expect(last_response.status).to eq(200)
171
- expect(stub).to have_been_requested
174
+ expect(regular_stub).to have_been_made.at_least_once
175
+ expect(env_stub).to have_been_made.at_least_once
172
176
  end
173
177
  end
174
178
 
@@ -187,7 +191,7 @@ RSpec.describe Flipper::Cloud::Middleware do
187
191
  post '/', request_body, env
188
192
 
189
193
  expect(last_response.status).to eq(200)
190
- expect(stub).to have_been_requested
194
+ expect(stub).to have_been_made.at_least_once
191
195
  end
192
196
  end
193
197
 
@@ -198,7 +202,9 @@ RSpec.describe Flipper::Cloud::Middleware do
198
202
  }
199
203
 
200
204
  it 'uses provided env key instead of default' do
201
- stub = stub_request_for_token('env')
205
+ regular_poll_stub = stub_request_for_token('regular')
206
+ env_poll_stub = stub_request_for_token('env')
207
+ env_webhook_stub = stub_request_for_token('env', from_webhook: true)
202
208
  env = {
203
209
  "HTTP_FLIPPER_CLOUD_SIGNATURE" => signature_header_value,
204
210
  'flipper' => flipper,
@@ -207,7 +213,9 @@ RSpec.describe Flipper::Cloud::Middleware do
207
213
  post '/', request_body, env
208
214
 
209
215
  expect(last_response.status).to eq(200)
210
- expect(stub).to have_been_requested
216
+ expect(regular_poll_stub).to have_been_made.at_least_once
217
+ expect(env_poll_stub).to have_been_made.at_least_once
218
+ expect(env_webhook_stub).not_to have_been_requested
211
219
  end
212
220
  end
213
221
 
@@ -222,7 +230,7 @@ RSpec.describe Flipper::Cloud::Middleware do
222
230
  post '/', request_body, env
223
231
 
224
232
  expect(last_response.status).to eq(200)
225
- expect(stub).to have_been_requested
233
+ expect(stub).to have_been_made.at_least_once
226
234
  end
227
235
  end
228
236
 
@@ -252,12 +260,13 @@ RSpec.describe Flipper::Cloud::Middleware do
252
260
  {"name" => "premium"},
253
261
  ],
254
262
  })
255
- expect(stub).to have_been_requested
263
+ expect(stub).to have_been_made.at_least_once
256
264
  end
257
265
  end
258
266
 
259
267
  describe 'Request method unsupported' do
260
268
  it 'skips middleware' do
269
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
261
270
  get '/'
262
271
  expect(last_response.status).to eq(404)
263
272
  expect(last_response.content_type).to eq("application/json")
@@ -267,14 +276,23 @@ RSpec.describe Flipper::Cloud::Middleware do
267
276
 
268
277
  describe 'Inspecting the built Rack app' do
269
278
  it 'returns a String' do
279
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
270
280
  expect(Flipper::Cloud.app(flipper).inspect).to eq("Flipper::Cloud")
271
281
  end
272
282
  end
273
283
 
274
284
  private
275
285
 
276
- def stub_request_for_token(token, status: 200)
277
- stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
286
+ def stub_request_for_token(token, status: 200, from_webhook: false)
287
+ if from_webhook
288
+ # Match URL with both exclude_gate_names=true and _cb=integer
289
+ url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true.*&_cb=\d+}
290
+ else
291
+ # Match URL with just exclude_gate_names=true
292
+ url_pattern = %r{https://www\.flippercloud\.io/adapter/features\?.*exclude_gate_names=true}
293
+ end
294
+
295
+ stub = stub_request(:get, url_pattern).
278
296
  with({
279
297
  headers: {
280
298
  '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.times(5)
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