flipper 1.3.2 → 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 (95) 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 +93 -0
  7. data/Gemfile +6 -2
  8. data/README.md +4 -3
  9. data/examples/cloud/backoff_policy.rb +1 -1
  10. data/examples/cloud/poll_interval/README.md +111 -0
  11. data/examples/cloud/poll_interval/client.rb +108 -0
  12. data/examples/cloud/poll_interval/server.rb +98 -0
  13. data/examples/expressions.rb +35 -11
  14. data/lib/flipper/adapter.rb +17 -1
  15. data/lib/flipper/adapters/actor_limit.rb +27 -1
  16. data/lib/flipper/adapters/cache_base.rb +21 -3
  17. data/lib/flipper/adapters/dual_write.rb +6 -2
  18. data/lib/flipper/adapters/failover.rb +9 -3
  19. data/lib/flipper/adapters/failsafe.rb +2 -2
  20. data/lib/flipper/adapters/http/client.rb +15 -4
  21. data/lib/flipper/adapters/http/error.rb +1 -1
  22. data/lib/flipper/adapters/http.rb +39 -4
  23. data/lib/flipper/adapters/instrumented.rb +2 -2
  24. data/lib/flipper/adapters/memoizable.rb +3 -3
  25. data/lib/flipper/adapters/memory.rb +1 -1
  26. data/lib/flipper/adapters/poll.rb +15 -0
  27. data/lib/flipper/adapters/pstore.rb +1 -1
  28. data/lib/flipper/adapters/strict.rb +30 -0
  29. data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
  30. data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
  31. data/lib/flipper/adapters/sync.rb +7 -3
  32. data/lib/flipper/cli.rb +51 -0
  33. data/lib/flipper/cloud/configuration.rb +14 -6
  34. data/lib/flipper/cloud/dsl.rb +2 -2
  35. data/lib/flipper/cloud/middleware.rb +1 -1
  36. data/lib/flipper/cloud/migrate.rb +71 -0
  37. data/lib/flipper/cloud/telemetry/backoff_policy.rb +6 -3
  38. data/lib/flipper/cloud/telemetry/submitter.rb +3 -1
  39. data/lib/flipper/cloud/telemetry.rb +3 -3
  40. data/lib/flipper/cloud.rb +1 -0
  41. data/lib/flipper/dsl.rb +1 -1
  42. data/lib/flipper/export.rb +0 -2
  43. data/lib/flipper/expressions/all.rb +0 -2
  44. data/lib/flipper/expressions/feature_enabled.rb +34 -0
  45. data/lib/flipper/expressions/time.rb +8 -1
  46. data/lib/flipper/feature.rb +8 -1
  47. data/lib/flipper/gate.rb +1 -1
  48. data/lib/flipper/gates/expression.rb +2 -2
  49. data/lib/flipper/instrumentation/log_subscriber.rb +1 -2
  50. data/lib/flipper/instrumentation/statsd.rb +4 -2
  51. data/lib/flipper/instrumentation/subscriber.rb +0 -4
  52. data/lib/flipper/metadata.rb +1 -0
  53. data/lib/flipper/poller.rb +54 -11
  54. data/lib/flipper/version.rb +1 -1
  55. data/lib/flipper.rb +17 -1
  56. data/lib/generators/flipper/setup_generator.rb +5 -0
  57. data/lib/generators/flipper/templates/initializer.rb +45 -0
  58. data/spec/flipper/adapter_spec.rb +20 -0
  59. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  60. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  61. data/spec/flipper/adapters/failover_spec.rb +12 -0
  62. data/spec/flipper/adapters/http_spec.rb +241 -0
  63. data/spec/flipper/adapters/poll_spec.rb +41 -0
  64. data/spec/flipper/adapters/strict_spec.rb +62 -4
  65. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  66. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  67. data/spec/flipper/adapters/sync_spec.rb +13 -0
  68. data/spec/flipper/cli_spec.rb +51 -0
  69. data/spec/flipper/cloud/configuration_spec.rb +6 -0
  70. data/spec/flipper/cloud/dsl_spec.rb +11 -3
  71. data/spec/flipper/cloud/middleware_spec.rb +34 -16
  72. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  73. data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +3 -3
  74. data/spec/flipper/cloud/telemetry/submitter_spec.rb +4 -4
  75. data/spec/flipper/cloud/telemetry_spec.rb +6 -6
  76. data/spec/flipper/cloud_spec.rb +9 -4
  77. data/spec/flipper/dsl_spec.rb +0 -3
  78. data/spec/flipper/engine_spec.rb +3 -2
  79. data/spec/flipper/expressions/time_spec.rb +16 -0
  80. data/spec/flipper/feature_spec.rb +22 -11
  81. data/spec/flipper/gates/expression_spec.rb +82 -0
  82. data/spec/flipper/instrumentation/log_subscriber_spec.rb +1 -0
  83. data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +1 -1
  84. data/spec/flipper/middleware/memoizer_spec.rb +41 -11
  85. data/spec/flipper/model/active_record_spec.rb +11 -0
  86. data/spec/flipper/poller_spec.rb +347 -4
  87. data/spec/flipper_integration_spec.rb +133 -0
  88. data/spec/flipper_spec.rb +7 -2
  89. data/spec/spec_helper.rb +15 -5
  90. data/test_rails/generators/flipper/setup_generator_test.rb +5 -0
  91. data/test_rails/generators/flipper/update_generator_test.rb +1 -1
  92. data/test_rails/helper.rb +3 -0
  93. metadata +17 -111
  94. data/lib/flipper/expressions/duration.rb +0 -28
  95. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -66,4 +66,17 @@ RSpec.describe Flipper::Adapters::DualWrite do
66
66
  expect(remote_adapter.count(:disable)).to be(1)
67
67
  expect(local_adapter.count(:disable)).to be(1)
68
68
  end
69
+
70
+ describe '#adapter_stack' do
71
+ it 'returns the tree representation' do
72
+ expect(subject.adapter_stack).to eq("dual_write(local: operation_logger -> memory, remote: operation_logger -> memory)")
73
+ end
74
+
75
+ it 'shows nested adapters in the tree' do
76
+ memory = Flipper::Adapters::Memory.new
77
+ strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
78
+ adapter = described_class.new(memory, strict)
79
+ expect(adapter.adapter_stack).to eq("dual_write(local: memory, remote: strict -> memory)")
80
+ end
81
+ end
69
82
  end
@@ -126,4 +126,16 @@ RSpec.describe Flipper::Adapters::Failover do
126
126
  end
127
127
  end
128
128
  end
129
+
130
+ describe '#adapter_stack' do
131
+ it 'returns the tree representation' do
132
+ expect(subject.adapter_stack).to eq("failover(primary: memory, secondary: memory)")
133
+ end
134
+
135
+ it 'shows nested adapters in the tree' do
136
+ strict_primary = Flipper::Adapters::Strict.new(primary)
137
+ adapter = described_class.new(strict_primary, secondary)
138
+ expect(adapter.adapter_stack).to eq("failover(primary: strict -> memory, secondary: memory)")
139
+ end
140
+ end
129
141
  end
@@ -28,6 +28,7 @@ RSpec.describe Flipper::Adapters::Http do
28
28
  end
29
29
 
30
30
  before :all do
31
+ @started = false
31
32
  dir = FlipperRoot.join('tmp').tap(&:mkpath)
32
33
  log_path = dir.join('flipper_adapters_http_spec.log')
33
34
  @pstore_file = dir.join('flipper.pstore')
@@ -184,6 +185,246 @@ RSpec.describe Flipper::Adapters::Http do
184
185
  adapter.get_all
185
186
  }.to raise_error(Flipper::Adapters::Http::Error)
186
187
  end
188
+
189
+ it "stores ETag and sends If-None-Match on subsequent requests" do
190
+ features_response = {
191
+ "features" => [
192
+ {
193
+ "key" => "search",
194
+ "gates" => [
195
+ {"key" => "boolean", "value" => true}
196
+ ]
197
+ }
198
+ ]
199
+ }
200
+
201
+ # First request - server returns ETag
202
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
203
+ .to_return(
204
+ status: 200,
205
+ body: JSON.generate(features_response),
206
+ headers: { 'ETag' => '"abc123"' }
207
+ )
208
+
209
+ adapter = described_class.new(url: 'http://app.com/flipper')
210
+ result = adapter.get_all
211
+
212
+ expect(result).to have_key("search")
213
+
214
+ # Second request - should send If-None-Match header
215
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
216
+ .with(headers: { 'If-None-Match' => '"abc123"' })
217
+ .to_return(
218
+ status: 200,
219
+ body: JSON.generate(features_response),
220
+ headers: { 'ETag' => '"abc123"' }
221
+ )
222
+
223
+ etag_result = adapter.get_all
224
+
225
+ expect(etag_result).to eq(result)
226
+ expect(etag_result).to have_key("search")
227
+ expect(
228
+ a_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
229
+ .with(headers: { 'If-None-Match' => '"abc123"' })
230
+ ).to have_been_made.once
231
+ end
232
+
233
+ it "returns cached result when server returns 304 Not Modified" do
234
+ features_response = {
235
+ "features" => [
236
+ {
237
+ "key" => "search",
238
+ "gates" => [
239
+ {"key" => "boolean", "value" => true}
240
+ ]
241
+ }
242
+ ]
243
+ }
244
+
245
+ # First request - server returns ETag
246
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
247
+ .to_return(
248
+ status: 200,
249
+ body: JSON.generate(features_response),
250
+ headers: { 'ETag' => '"abc123"' }
251
+ )
252
+
253
+ adapter = described_class.new(url: 'http://app.com/flipper')
254
+ first_result = adapter.get_all
255
+
256
+ expect(first_result).to have_key("search")
257
+
258
+ # Second request - server returns 304
259
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
260
+ .with(headers: { 'If-None-Match' => '"abc123"' })
261
+ .to_return(status: 304, headers: { 'ETag' => '"abc123"' })
262
+
263
+ second_result = adapter.get_all
264
+
265
+ # Should return the cached result
266
+ expect(second_result).to eq(first_result)
267
+ expect(second_result).to have_key("search")
268
+ end
269
+
270
+ it "raises error when 304 received without cached result" do
271
+ # Server returns 304 without any prior request
272
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
273
+ .to_return(status: 304)
274
+
275
+ adapter = described_class.new(url: 'http://app.com/flipper')
276
+ expect {
277
+ adapter.get_all
278
+ }.to raise_error(Flipper::Adapters::Http::Error)
279
+ end
280
+
281
+ it "skips If-None-Match header when cache_bust is true" do
282
+ features_response = {
283
+ "features" => [
284
+ {
285
+ "key" => "search",
286
+ "gates" => [
287
+ {"key" => "boolean", "value" => true}
288
+ ]
289
+ }
290
+ ]
291
+ }
292
+
293
+ # First request - populate the ETag cache
294
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
295
+ .to_return(
296
+ status: 200,
297
+ body: JSON.generate(features_response),
298
+ headers: { 'ETag' => '"abc123"' }
299
+ )
300
+
301
+ adapter = described_class.new(url: 'http://app.com/flipper')
302
+ adapter.get_all
303
+
304
+ # Second request with cache_bust - should NOT send If-None-Match
305
+ cache_bust_stub = stub_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
306
+ .to_return(
307
+ status: 200,
308
+ body: JSON.generate(features_response),
309
+ headers: { 'ETag' => '"def456"' }
310
+ )
311
+
312
+ adapter.get_all(cache_bust: true)
313
+
314
+ expect(cache_bust_stub).to have_been_requested.once
315
+ expect(
316
+ a_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
317
+ .with { |req| req.headers['If-None-Match'].nil? }
318
+ ).to have_been_made.once
319
+ end
320
+
321
+ it "returns fresh data on cache_bust even when ETag is cached" do
322
+ stale_response = {
323
+ "features" => [
324
+ {
325
+ "key" => "search",
326
+ "gates" => [
327
+ {"key" => "boolean", "value" => nil}
328
+ ]
329
+ }
330
+ ]
331
+ }
332
+
333
+ fresh_response = {
334
+ "features" => [
335
+ {
336
+ "key" => "search",
337
+ "gates" => [
338
+ {"key" => "boolean", "value" => true}
339
+ ]
340
+ }
341
+ ]
342
+ }
343
+
344
+ # First request - populate ETag cache with feature disabled
345
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
346
+ .to_return(
347
+ status: 200,
348
+ body: JSON.generate(stale_response),
349
+ headers: { 'ETag' => '"abc123"' }
350
+ )
351
+
352
+ adapter = described_class.new(url: 'http://app.com/flipper')
353
+ stale_result = adapter.get_all
354
+
355
+ expect(stale_result["search"][:boolean]).to be_nil
356
+
357
+ # Cache bust request returns fresh data (feature now enabled)
358
+ stub_request(:get, %r{/flipper/features\?_cb=\d+&exclude_gate_names=true})
359
+ .to_return(
360
+ status: 200,
361
+ body: JSON.generate(fresh_response),
362
+ headers: { 'ETag' => '"def456"' }
363
+ )
364
+
365
+ fresh_result = adapter.get_all(cache_bust: true)
366
+
367
+ expect(fresh_result["search"][:boolean]).to eq("true")
368
+ end
369
+
370
+ it "does not send If-None-Match for other endpoints" do
371
+ stub_request(:get, "http://app.com/flipper/features/search")
372
+ .to_return(status: 404)
373
+
374
+ adapter = described_class.new(url: 'http://app.com/flipper')
375
+ adapter.get(flipper[:search])
376
+
377
+ # Verify no If-None-Match header was sent
378
+ expect(
379
+ a_request(:get, "http://app.com/flipper/features/search")
380
+ .with { |req| req.headers['If-None-Match'].nil? }
381
+ ).to have_been_made.once
382
+ end
383
+
384
+ it "stores last get_all response for poll-shutdown header checking" do
385
+ features_response = {
386
+ "features" => [
387
+ {
388
+ "key" => "search",
389
+ "gates" => [
390
+ {"key" => "boolean", "value" => true}
391
+ ]
392
+ }
393
+ ]
394
+ }
395
+
396
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
397
+ .to_return(
398
+ status: 200,
399
+ body: JSON.generate(features_response),
400
+ headers: { 'poll-shutdown' => 'true' }
401
+ )
402
+
403
+ adapter = described_class.new(url: 'http://app.com/flipper')
404
+ adapter.get_all
405
+
406
+ expect(adapter.last_get_all_response).not_to be_nil
407
+ expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
408
+ end
409
+
410
+ it "stores last get_all response even for error responses" do
411
+ stub_request(:get, "http://app.com/flipper/features?exclude_gate_names=true")
412
+ .to_return(
413
+ status: 404,
414
+ body: JSON.generate({ error: "Not found" }),
415
+ headers: { 'poll-shutdown' => 'true' }
416
+ )
417
+
418
+ adapter = described_class.new(url: 'http://app.com/flipper')
419
+
420
+ expect {
421
+ adapter.get_all
422
+ }.to raise_error(Flipper::Adapters::Http::Error)
423
+
424
+ # Even though it raised an error, response should be stored
425
+ expect(adapter.last_get_all_response).not_to be_nil
426
+ expect(adapter.last_get_all_response['poll-shutdown']).to eq('true')
427
+ end
187
428
  end
188
429
 
189
430
  describe "#features" do
@@ -0,0 +1,41 @@
1
+ require 'flipper/adapters/poll'
2
+
3
+ RSpec.describe Flipper::Adapters::Poll do
4
+ let(:remote_adapter) {
5
+ adapter = Flipper::Adapters::Memory.new(threadsafe: true)
6
+ flipper = Flipper.new(adapter)
7
+ flipper.enable(:search)
8
+ flipper.enable(:analytics)
9
+ adapter
10
+ }
11
+ let(:local_adapter) { Flipper::Adapters::Memory.new(threadsafe: true) }
12
+ let(:poller) {
13
+ Flipper::Poller.get("for_spec", {
14
+ start_automatically: false,
15
+ remote_adapter: remote_adapter,
16
+ })
17
+ }
18
+
19
+ it "syncs in main thread if local adapter is empty" do
20
+ instance = described_class.new(poller, local_adapter)
21
+ instance.features # call something to force sync
22
+ expect(local_adapter.features).to eq(remote_adapter.features)
23
+ end
24
+
25
+ it "does not sync in main thread if local adapter is not empty" do
26
+ # make local not empty by importing remote
27
+ flipper = Flipper.new(local_adapter)
28
+ flipper.import(remote_adapter)
29
+
30
+ # make a fake poller to verify calls
31
+ poller = double("Poller", last_synced_at: Concurrent::AtomicFixnum.new(0))
32
+ expect(poller).to receive(:start).twice
33
+ expect(poller).not_to receive(:sync)
34
+
35
+ # create new instance and call something to force sync
36
+ instance = described_class.new(poller, local_adapter)
37
+ instance.features # call something to force sync
38
+
39
+ expect(local_adapter.features).to eq(remote_adapter.features)
40
+ end
41
+ end
@@ -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