flipper 1.3.6 → 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.
Files changed (65) 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 +2 -1
  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 +49 -8
  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/middleware_spec.rb +1 -1
  53. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  54. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  55. data/spec/flipper/engine_spec.rb +2 -2
  56. data/spec/flipper/expressions/time_spec.rb +16 -0
  57. data/spec/flipper/gates/expression_spec.rb +82 -0
  58. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  59. data/spec/flipper/poller_spec.rb +347 -4
  60. data/spec/flipper_integration_spec.rb +133 -0
  61. data/spec/flipper_spec.rb +5 -0
  62. data/spec/spec_helper.rb +7 -0
  63. metadata +17 -112
  64. data/lib/flipper/expressions/duration.rb +0 -28
  65. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -1,8 +1,15 @@
1
+ require "time"
2
+
1
3
  module Flipper
2
4
  module Expressions
3
5
  class Time
4
6
  def self.call(value)
5
- ::Time.parse(value)
7
+ case value
8
+ when Numeric
9
+ ::Time.at(value).utc
10
+ else
11
+ ::Time.parse(value).utc
12
+ end
6
13
  end
7
14
  end
8
15
  end
@@ -30,10 +30,10 @@ module Flipper
30
30
  expression = Flipper::Expression.build(data)
31
31
 
32
32
  if context.actors.nil? || context.actors.empty?
33
- !!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES)
33
+ !!expression.evaluate(feature_name: context.feature_name, properties: DEFAULT_PROPERTIES, actor: nil)
34
34
  else
35
35
  context.actors.any? do |actor|
36
- !!expression.evaluate(feature_name: context.feature_name, properties: properties(actor))
36
+ !!expression.evaluate(feature_name: context.feature_name, properties: properties(actor), actor: actor)
37
37
  end
38
38
  end
39
39
  end
@@ -2,6 +2,7 @@ require 'logger'
2
2
  require 'concurrent/utility/monotonic_time'
3
3
  require 'concurrent/map'
4
4
  require 'concurrent/atomic/atomic_fixnum'
5
+ require 'concurrent/atomic/atomic_boolean'
5
6
 
6
7
  module Flipper
7
8
  class Poller
@@ -17,7 +18,10 @@ module Flipper
17
18
  end
18
19
 
19
20
  def self.reset
20
- instances.each {|_, instance| instance.stop }.clear
21
+ instances.each do |_, instance|
22
+ instance.stop
23
+ instance.thread&.join(1)
24
+ end.clear
21
25
  end
22
26
 
23
27
  MINIMUM_POLL_INTERVAL = 10
@@ -28,14 +32,12 @@ module Flipper
28
32
  @mutex = Mutex.new
29
33
  @instrumenter = options.fetch(:instrumenter, Instrumenters::Noop)
30
34
  @remote_adapter = options.fetch(:remote_adapter)
31
- @interval = options.fetch(:interval, 10).to_f
32
35
  @last_synced_at = Concurrent::AtomicFixnum.new(0)
33
36
  @adapter = Adapters::Memory.new(nil, threadsafe: true)
37
+ @shutdown_requested = Concurrent::AtomicBoolean.new(false)
34
38
 
35
- if @interval < MINIMUM_POLL_INTERVAL
36
- warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{@interval}. Setting @interval to #{MINIMUM_POLL_INTERVAL}."
37
- @interval = MINIMUM_POLL_INTERVAL
38
- end
39
+ self.interval = options.fetch(:interval, 10)
40
+ @initial_interval = @interval
39
41
 
40
42
  @start_automatically = options.fetch(:start_automatically, true)
41
43
 
@@ -46,6 +48,7 @@ module Flipper
46
48
 
47
49
  def start
48
50
  reset if forked?
51
+ return if @shutdown_requested.true?
49
52
  ensure_worker_running
50
53
  end
51
54
 
@@ -72,9 +75,25 @@ module Flipper
72
75
 
73
76
  def sync
74
77
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", operation: :poll) do
75
- @adapter.import @remote_adapter
76
- @last_synced_at.update { |time| Concurrent.monotonic_time }
78
+ begin
79
+ @adapter.import @remote_adapter
80
+ @last_synced_at.update { |time| Concurrent.monotonic_time }
81
+ ensure
82
+ apply_response_headers
83
+ end
84
+ end
85
+ end
86
+
87
+ # Internal: Sets the interval in seconds for how often to poll.
88
+ def interval=(value)
89
+ requested_interval = Flipper::Typecast.to_float(value)
90
+ new_interval = [requested_interval, MINIMUM_POLL_INTERVAL].max
91
+
92
+ if requested_interval < MINIMUM_POLL_INTERVAL
93
+ warn "Flipper::Cloud poll interval must be greater than or equal to #{MINIMUM_POLL_INTERVAL} but was #{requested_interval}. Setting interval to #{MINIMUM_POLL_INTERVAL}."
77
94
  end
95
+
96
+ @interval = new_interval
78
97
  end
79
98
 
80
99
  private
@@ -98,6 +117,7 @@ module Flipper
98
117
  begin
99
118
  return if thread_alive?
100
119
  @thread = Thread.new { run }
120
+ @thread&.report_on_exception = false
101
121
  @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
102
122
  operation: :thread_start,
103
123
  })
@@ -112,7 +132,28 @@ module Flipper
112
132
 
113
133
  def reset
114
134
  @pid = Process.pid
135
+ @shutdown_requested.make_false
115
136
  mutex.unlock if mutex.locked?
116
137
  end
138
+
139
+ def apply_response_headers
140
+ return unless @remote_adapter.respond_to?(:last_get_all_response)
141
+
142
+ if response = @remote_adapter.last_get_all_response
143
+ # shutdown based on response header
144
+ if Flipper::Typecast.to_boolean(response["poll-shutdown"])
145
+ @shutdown_requested.make_true
146
+ @instrumenter.instrument("poller.#{InstrumentationNamespace}", {
147
+ operation: :shutdown_requested,
148
+ })
149
+ stop
150
+ end
151
+
152
+ # update interval based on response header
153
+ if interval = response["poll-interval"]
154
+ self.interval = [Flipper::Typecast.to_float(interval), @initial_interval].max
155
+ end
156
+ end
157
+ end
117
158
  end
118
159
  end
@@ -1,5 +1,5 @@
1
1
  module Flipper
2
- VERSION = '1.3.6'.freeze
2
+ VERSION = '1.4.2'.freeze
3
3
 
4
4
  REQUIRED_RUBY_VERSION = '2.6'.freeze
5
5
  NEXT_REQUIRED_RUBY_VERSION = '3.0'.freeze
data/lib/flipper.rb CHANGED
@@ -64,7 +64,7 @@ module Flipper
64
64
  :enable_percentage_of_actors, :disable_percentage_of_actors,
65
65
  :enable_percentage_of_time, :disable_percentage_of_time,
66
66
  :features, :feature, :[], :preload, :preload_all,
67
- :adapter, :add, :exist?, :remove, :import, :export,
67
+ :adapter, :adapter_stack, :add, :exist?, :remove, :import, :export,
68
68
  :memoize=, :memoizing?, :read_only?,
69
69
  :sync, :sync_secret # For Flipper::Cloud. Will error for OSS Flipper.
70
70
 
@@ -100,6 +100,22 @@ module Flipper
100
100
  Expression.build({ Random: max })
101
101
  end
102
102
 
103
+ def now
104
+ Expression.build({ Now: [] })
105
+ end
106
+
107
+ def time(value)
108
+ Expression.build({ Time: value })
109
+ end
110
+
111
+ def feature_enabled(name)
112
+ Expression.build({ FeatureEnabled: name })
113
+ end
114
+
115
+ def feature_disabled(name)
116
+ feature_enabled(name).eq(false)
117
+ end
118
+
103
119
  # Public: Use this to register a group by name.
104
120
  #
105
121
  # name - The Symbol name of the group.
@@ -143,4 +143,24 @@ RSpec.describe Flipper::Adapter do
143
143
  expect(export.features.dig("search", :boolean)).to eq("true")
144
144
  end
145
145
  end
146
+
147
+ describe "#adapter_stack" do
148
+ it "returns the adapter name for a simple adapter" do
149
+ adapter = Flipper::Adapters::Memory.new
150
+ expect(adapter.adapter_stack).to eq("memory")
151
+ end
152
+
153
+ it "returns the chain for wrapped adapters" do
154
+ memory = Flipper::Adapters::Memory.new
155
+ memoizable = Flipper::Adapters::Memoizable.new(memory)
156
+ expect(memoizable.adapter_stack).to eq("memoizable -> memory")
157
+ end
158
+
159
+ it "returns the chain for deeply nested adapters" do
160
+ memory = Flipper::Adapters::Memory.new
161
+ strict = Flipper::Adapters::Strict.new(memory)
162
+ memoizable = Flipper::Adapters::Memoizable.new(strict)
163
+ expect(memoizable.adapter_stack).to eq("memoizable -> strict -> memory")
164
+ end
165
+ end
146
166
  end
@@ -15,6 +15,61 @@ RSpec.describe Flipper::Adapters::ActorLimit do
15
15
  feature.enable Flipper::Actor.new("User;6")
16
16
  }.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
17
17
  end
18
+
19
+ it "allows exceeding limit when in sync mode" do
20
+ 5.times { |i| feature.enable Flipper::Actor.new("User;#{i}") }
21
+
22
+ described_class.with_sync_mode do
23
+ expect {
24
+ feature.enable Flipper::Actor.new("User;6")
25
+ }.not_to raise_error
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ describe '.sync_mode' do
32
+ after do
33
+ described_class.sync_mode = nil
34
+ end
35
+
36
+ it 'defaults to nil/falsy' do
37
+ expect(described_class.sync_mode).to be_falsy
38
+ end
39
+
40
+ it 'can be set and read' do
41
+ described_class.sync_mode = true
42
+ expect(described_class.sync_mode).to be true
43
+ end
44
+ end
45
+
46
+ describe '.with_sync_mode' do
47
+ after do
48
+ described_class.sync_mode = nil
49
+ end
50
+
51
+ it 'sets sync_mode to true within block' do
52
+ described_class.with_sync_mode do
53
+ expect(described_class.sync_mode).to be true
54
+ end
55
+ end
56
+
57
+ it 'restores previous value after block' do
58
+ expect(described_class.sync_mode).to be_falsy
59
+ described_class.with_sync_mode { }
60
+ expect(described_class.sync_mode).to be_falsy
61
+ end
62
+
63
+ it 'restores previous value even on exception' do
64
+ expect {
65
+ described_class.with_sync_mode { raise "boom" }
66
+ }.to raise_error("boom")
67
+ expect(described_class.sync_mode).to be_falsy
68
+ end
69
+
70
+ it 'returns the block result' do
71
+ result = described_class.with_sync_mode { 42 }
72
+ expect(result).to eq(42)
18
73
  end
19
74
  end
20
75
  end
@@ -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
@@ -185,6 +185,246 @@ RSpec.describe Flipper::Adapters::Http do
185
185
  adapter.get_all
186
186
  }.to raise_error(Flipper::Adapters::Http::Error)
187
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
188
428
  end
189
429
 
190
430
  describe "#features" do
@@ -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"))