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
@@ -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",
@@ -45,7 +51,7 @@ RSpec.describe Flipper::Cloud::DSL do
45
51
  end
46
52
 
47
53
  let(:cloud_configuration) do
48
- cloud_configuration = Flipper::Cloud::Configuration.new({
54
+ Flipper::Cloud::Configuration.new({
49
55
  token: "asdf",
50
56
  sync_secret: "tasty",
51
57
  local_adapter: local_adapter
@@ -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
@@ -4,8 +4,8 @@ RSpec.describe Flipper::Cloud::Telemetry::BackoffPolicy do
4
4
  context "#initialize" do
5
5
  it "with no options" do
6
6
  policy = described_class.new
7
- expect(policy.min_timeout_ms).to eq(1_000)
8
- expect(policy.max_timeout_ms).to eq(30_000)
7
+ expect(policy.min_timeout_ms).to eq(30_000)
8
+ expect(policy.max_timeout_ms).to eq(120_000)
9
9
  expect(policy.multiplier).to eq(1.5)
10
10
  expect(policy.randomization_factor).to eq(0.5)
11
11
  end
@@ -87,7 +87,7 @@ RSpec.describe Flipper::Cloud::Telemetry::BackoffPolicy do
87
87
  randomization_factor: 0.5,
88
88
  })
89
89
  10.times { policy.next_interval }
90
- expect(policy.next_interval).to eq(10_000)
90
+ expect(policy.next_interval).to be_within(10_000*0.1).of(10_000)
91
91
  end
92
92
  end
93
93
 
@@ -71,15 +71,15 @@ RSpec.describe Flipper::Cloud::Telemetry::Submitter do
71
71
  to_return(status: 429, body: "{}").
72
72
  to_return(status: 200, body: "{}")
73
73
  instance = described_class.new(cloud_configuration)
74
- expect(instance.backoff_policy.min_timeout_ms).to eq(1_000)
75
- expect(instance.backoff_policy.max_timeout_ms).to eq(30_000)
74
+ expect(instance.backoff_policy.min_timeout_ms).to eq(30_000)
75
+ expect(instance.backoff_policy.max_timeout_ms).to eq(120_000)
76
76
  end
77
77
 
78
78
  it "tries 10 times by default" do
79
79
  stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
80
80
  to_return(status: 500, body: "{}")
81
81
  subject.call(enabled_metrics)
82
- expect(subject.backoff_policy.retries).to eq(9) # 9 retries + 1 initial attempt
82
+ expect(subject.backoff_policy.retries).to eq(4) # 4 retries + 1 initial attempt
83
83
  end
84
84
 
85
85
  [
@@ -105,7 +105,7 @@ RSpec.describe Flipper::Cloud::Telemetry::Submitter do
105
105
  stub_request(:post, "https://www.flippercloud.io/adapter/telemetry").
106
106
  to_raise(error_class)
107
107
  subject.call(enabled_metrics)
108
- expect(subject.backoff_policy.retries).to eq(9)
108
+ expect(subject.backoff_policy.retries).to eq(4)
109
109
  end
110
110
  end
111
111
 
@@ -25,7 +25,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
25
25
 
26
26
  expect(telemetry.interval).to eq(60)
27
27
  expect(telemetry.timer.execution_interval).to eq(60)
28
- expect(stub).to have_been_requested
28
+ expect(stub).to have_been_requested.at_least_once
29
29
  end
30
30
 
31
31
  it "phones home and updates telemetry interval if present" do
@@ -45,7 +45,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
45
45
 
46
46
  expect(telemetry.interval).to eq(120)
47
47
  expect(telemetry.timer.execution_interval).to eq(120)
48
- expect(stub).to have_been_requested
48
+ expect(stub).to have_been_requested.at_least_once
49
49
  end
50
50
 
51
51
  it "phones home and requests shutdown if telemetry-shutdown header is true" do
@@ -67,7 +67,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
67
67
  result: true,
68
68
  })
69
69
  telemetry.stop
70
- expect(stub).to have_been_requested
70
+ expect(stub).to have_been_requested.at_least_once
71
71
  expect(output.string).to match(/action=telemetry_shutdown message=The server has requested that telemetry be shut down./)
72
72
  end
73
73
 
@@ -90,7 +90,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
90
90
  result: true,
91
91
  })
92
92
  telemetry.stop
93
- expect(stub).to have_been_requested
93
+ expect(stub).to have_been_requested.at_least_once
94
94
  expect(output.string).not_to match(/action=telemetry_shutdown message=The server has requested that telemetry be shut down./)
95
95
  end
96
96
 
@@ -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(10)
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
@@ -152,7 +152,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
152
152
 
153
153
  expect(telemetry.interval).to eq(60)
154
154
  expect(telemetry.timer.execution_interval).to eq(60)
155
- expect(stub).to have_been_requested.times(10)
155
+ expect(stub).to have_been_requested.times(5)
156
156
  end
157
157
 
158
158
  describe '#record' do
@@ -43,6 +43,7 @@ RSpec.describe Flipper::Cloud do
43
43
 
44
44
  context 'initialize with token and options' do
45
45
  it 'sets correct url' do
46
+ stub_request(:any, %r{fakeflipper.com}).to_return(status: 200)
46
47
  instance = described_class.new(token: 'asdf', url: 'https://www.fakeflipper.com/sadpanda')
47
48
  # pardon the nesting...
48
49
  memoized = instance.adapter
@@ -78,27 +79,31 @@ RSpec.describe Flipper::Cloud do
78
79
  end
79
80
 
80
81
  it 'can set debug_output' do
82
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
81
83
  expect(Flipper::Adapters::Http::Client).to receive(:new)
82
- .with(hash_including(debug_output: STDOUT)).at_least(:once)
84
+ .with(hash_including(debug_output: STDOUT)).at_least(:once).and_return(instance)
83
85
  described_class.new(token: 'asdf', debug_output: STDOUT)
84
86
  end
85
87
 
86
88
  it 'can set read_timeout' do
89
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
87
90
  expect(Flipper::Adapters::Http::Client).to receive(:new)
88
- .with(hash_including(read_timeout: 1)).at_least(:once)
91
+ .with(hash_including(read_timeout: 1)).at_least(:once).and_return(instance)
89
92
  described_class.new(token: 'asdf', read_timeout: 1)
90
93
  end
91
94
 
92
95
  it 'can set open_timeout' do
96
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
93
97
  expect(Flipper::Adapters::Http::Client).to receive(:new)
94
- .with(hash_including(open_timeout: 1)).at_least(:once)
98
+ .with(hash_including(open_timeout: 1)).at_least(:once).and_return(instance)
95
99
  described_class.new(token: 'asdf', open_timeout: 1)
96
100
  end
97
101
 
98
102
  if RUBY_VERSION >= '2.6.0'
99
103
  it 'can set write_timeout' do
104
+ instance = Flipper::Adapters::Http::Client.new(token: 'asdf', url: 'https://www.flippercloud.io/adapter')
100
105
  expect(Flipper::Adapters::Http::Client).to receive(:new)
101
- .with(hash_including(open_timeout: 1)).at_least(:once)
106
+ .with(hash_including(open_timeout: 1)).at_least(:once).and_return(instance)
102
107
  described_class.new(token: 'asdf', open_timeout: 1)
103
108
  end
104
109
  end
@@ -225,9 +225,6 @@ RSpec.describe Flipper::DSL do
225
225
 
226
226
  describe '#enable_group/disable_group' do
227
227
  it 'enables and disables the feature for group' do
228
- actor = Flipper::Actor.new(5)
229
- group = Flipper.register(:fives) { |actor| actor.flipper_id == 5 }
230
-
231
228
  expect(subject[:stats].groups_value).to be_empty
232
229
  subject.enable_group(:stats, :fives)
233
230
  expect(subject[:stats].groups_value).to eq(Set['fives'])
@@ -12,6 +12,7 @@ RSpec.describe Flipper::Engine do
12
12
  end
13
13
 
14
14
  before do
15
+ stub_request(:get, /flippercloud\.io/).to_return(status: 200, body: "{}")
15
16
  Rails.application = nil
16
17
  ActiveSupport::Dependencies.autoload_paths = ActiveSupport::Dependencies.autoload_paths.dup
17
18
  ActiveSupport::Dependencies.autoload_once_paths = ActiveSupport::Dependencies.autoload_once_paths.dup
@@ -262,10 +263,10 @@ RSpec.describe Flipper::Engine do
262
263
  Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
263
264
  }
264
265
 
265
- it "configures webhook app" do
266
+ it "configures webhook app and uses cache busting" do
266
267
  silence { application.initialize! }
267
268
 
268
- stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").with({
269
+ stub = stub_request(:get, /https:\/\/www\.flippercloud\.io\/adapter\/features\?_cb=\d+&exclude_gate_names=true/).with({
269
270
  headers: { "flipper-cloud-token" => ENV["FLIPPER_CLOUD_TOKEN"] },
270
271
  }).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {})
271
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
@@ -76,6 +76,26 @@ RSpec.describe Flipper::Feature do
76
76
  expect(subject.enabled?(actors)).to be(false)
77
77
  end
78
78
  end
79
+
80
+ context "for an object that implements .nil? == true" do
81
+ let(:actor) { Flipper::Actor.new("User;1") }
82
+
83
+ before do
84
+ def actor.nil?
85
+ true
86
+ end
87
+ end
88
+
89
+ it 'returns true if feature is enabled' do
90
+ subject.enable
91
+ expect(subject.enabled?(actor)).to be(true)
92
+ end
93
+
94
+ it 'returns false if feature is disabled' do
95
+ subject.disable
96
+ expect(subject.enabled?(actor)).to be(false)
97
+ end
98
+ end
79
99
  end
80
100
 
81
101
  describe '#to_s' do
@@ -195,7 +215,7 @@ RSpec.describe Flipper::Feature do
195
215
 
196
216
  it 'is recorded for enable' do
197
217
  actor = Flipper::Types::Actor.new(Flipper::Actor.new('1'))
198
- gate = subject.gate_for(actor)
218
+ subject.gate_for(actor)
199
219
 
200
220
  subject.enable(actor)
201
221
 
@@ -210,7 +230,7 @@ RSpec.describe Flipper::Feature do
210
230
 
211
231
  it 'always instruments flipper type instance for enable' do
212
232
  actor = Flipper::Actor.new('1')
213
- gate = subject.gate_for(actor)
233
+ subject.gate_for(actor)
214
234
 
215
235
  subject.enable(actor)
216
236
 
@@ -221,7 +241,6 @@ RSpec.describe Flipper::Feature do
221
241
 
222
242
  it 'is recorded for disable' do
223
243
  thing = Flipper::Types::Boolean.new
224
- gate = subject.gate_for(thing)
225
244
 
226
245
  subject.disable(thing)
227
246
 
@@ -266,7 +285,6 @@ RSpec.describe Flipper::Feature do
266
285
 
267
286
  it 'always instruments flipper type instance for disable' do
268
287
  actor = Flipper::Actor.new('1')
269
- gate = subject.gate_for(actor)
270
288
 
271
289
  subject.disable(actor)
272
290
 
@@ -709,7 +727,6 @@ RSpec.describe Flipper::Feature do
709
727
  context "with expression instance" do
710
728
  it "updates gate values to equal expression or clears expression" do
711
729
  expression = Flipper.property(:plan).eq("basic")
712
- other_expression = Flipper.property(:age).gte(21)
713
730
  expect(subject.gate_values.expression).to be(nil)
714
731
  subject.enable_expression(expression)
715
732
  expect(subject.gate_values.expression).to eq(expression.value)
@@ -721,7 +738,6 @@ RSpec.describe Flipper::Feature do
721
738
  context "with Hash" do
722
739
  it "updates gate values to equal expression or clears expression" do
723
740
  expression = Flipper.property(:plan).eq("basic")
724
- other_expression = Flipper.property(:age).gte(21)
725
741
  expect(subject.gate_values.expression).to be(nil)
726
742
  subject.enable_expression(expression.value)
727
743
  expect(subject.gate_values.expression).to eq(expression.value)
@@ -1078,8 +1094,6 @@ RSpec.describe Flipper::Feature do
1078
1094
  describe '#enable_group/disable_group' do
1079
1095
  context 'with symbol group name' do
1080
1096
  it 'updates the gate values to include the group' do
1081
- actor = Flipper::Actor.new(5)
1082
- group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 }
1083
1097
  expect(subject.gate_values.groups).to be_empty
1084
1098
  subject.enable_group(:five_only)
1085
1099
  expect(subject.gate_values.groups).to eq(Set['five_only'])
@@ -1090,8 +1104,6 @@ RSpec.describe Flipper::Feature do
1090
1104
 
1091
1105
  context 'with string group name' do
1092
1106
  it 'updates the gate values to include the group' do
1093
- actor = Flipper::Actor.new(5)
1094
- group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 }
1095
1107
  expect(subject.gate_values.groups).to be_empty
1096
1108
  subject.enable_group('five_only')
1097
1109
  expect(subject.gate_values.groups).to eq(Set['five_only'])
@@ -1102,7 +1114,6 @@ RSpec.describe Flipper::Feature do
1102
1114
 
1103
1115
  context 'with group instance' do
1104
1116
  it 'updates the gate values for the group' do
1105
- actor = Flipper::Actor.new(5)
1106
1117
  group = Flipper.register(:five_only) { |actor| actor.flipper_id == 5 }
1107
1118
  expect(subject.gate_values.groups).to be_empty
1108
1119
  subject.enable_group(group)