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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -6
- data/.github/workflows/examples.yml +5 -4
- data/.github/workflows/release.yml +54 -0
- data/.superset/config.json +4 -0
- data/CLAUDE.md +93 -0
- data/Gemfile +6 -2
- data/README.md +4 -3
- data/examples/cloud/backoff_policy.rb +1 -1
- data/examples/cloud/poll_interval/README.md +111 -0
- data/examples/cloud/poll_interval/client.rb +108 -0
- data/examples/cloud/poll_interval/server.rb +98 -0
- data/examples/expressions.rb +35 -11
- data/lib/flipper/adapter.rb +17 -1
- data/lib/flipper/adapters/actor_limit.rb +27 -1
- data/lib/flipper/adapters/cache_base.rb +21 -3
- data/lib/flipper/adapters/dual_write.rb +6 -2
- data/lib/flipper/adapters/failover.rb +9 -3
- data/lib/flipper/adapters/failsafe.rb +2 -2
- data/lib/flipper/adapters/http/client.rb +15 -4
- data/lib/flipper/adapters/http/error.rb +1 -1
- data/lib/flipper/adapters/http.rb +39 -4
- data/lib/flipper/adapters/instrumented.rb +2 -2
- data/lib/flipper/adapters/memoizable.rb +3 -3
- data/lib/flipper/adapters/memory.rb +1 -1
- data/lib/flipper/adapters/poll.rb +15 -0
- data/lib/flipper/adapters/pstore.rb +1 -1
- data/lib/flipper/adapters/strict.rb +30 -0
- data/lib/flipper/adapters/sync/feature_synchronizer.rb +5 -1
- data/lib/flipper/adapters/sync/synchronizer.rb +13 -5
- data/lib/flipper/adapters/sync.rb +7 -3
- data/lib/flipper/cli.rb +51 -0
- data/lib/flipper/cloud/configuration.rb +14 -6
- data/lib/flipper/cloud/dsl.rb +2 -2
- data/lib/flipper/cloud/middleware.rb +1 -1
- data/lib/flipper/cloud/migrate.rb +71 -0
- data/lib/flipper/cloud/telemetry/backoff_policy.rb +6 -3
- data/lib/flipper/cloud/telemetry/submitter.rb +3 -1
- data/lib/flipper/cloud/telemetry.rb +3 -3
- data/lib/flipper/cloud.rb +1 -0
- data/lib/flipper/dsl.rb +1 -1
- data/lib/flipper/export.rb +0 -2
- data/lib/flipper/expressions/all.rb +0 -2
- data/lib/flipper/expressions/feature_enabled.rb +34 -0
- data/lib/flipper/expressions/time.rb +8 -1
- data/lib/flipper/feature.rb +8 -1
- data/lib/flipper/gate.rb +1 -1
- data/lib/flipper/gates/expression.rb +2 -2
- data/lib/flipper/instrumentation/log_subscriber.rb +1 -2
- data/lib/flipper/instrumentation/statsd.rb +4 -2
- data/lib/flipper/instrumentation/subscriber.rb +0 -4
- data/lib/flipper/metadata.rb +1 -0
- data/lib/flipper/poller.rb +54 -11
- data/lib/flipper/version.rb +1 -1
- data/lib/flipper.rb +17 -1
- data/lib/generators/flipper/setup_generator.rb +5 -0
- data/lib/generators/flipper/templates/initializer.rb +45 -0
- data/spec/flipper/adapter_spec.rb +20 -0
- data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
- data/spec/flipper/adapters/dual_write_spec.rb +13 -0
- data/spec/flipper/adapters/failover_spec.rb +12 -0
- data/spec/flipper/adapters/http_spec.rb +241 -0
- data/spec/flipper/adapters/poll_spec.rb +41 -0
- data/spec/flipper/adapters/strict_spec.rb +62 -4
- data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
- data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
- data/spec/flipper/adapters/sync_spec.rb +13 -0
- data/spec/flipper/cli_spec.rb +51 -0
- data/spec/flipper/cloud/configuration_spec.rb +6 -0
- data/spec/flipper/cloud/dsl_spec.rb +11 -3
- data/spec/flipper/cloud/middleware_spec.rb +34 -16
- data/spec/flipper/cloud/migrate_spec.rb +160 -0
- data/spec/flipper/cloud/telemetry/backoff_policy_spec.rb +3 -3
- data/spec/flipper/cloud/telemetry/submitter_spec.rb +4 -4
- data/spec/flipper/cloud/telemetry_spec.rb +6 -6
- data/spec/flipper/cloud_spec.rb +9 -4
- data/spec/flipper/dsl_spec.rb +0 -3
- data/spec/flipper/engine_spec.rb +3 -2
- data/spec/flipper/expressions/time_spec.rb +16 -0
- data/spec/flipper/feature_spec.rb +22 -11
- data/spec/flipper/gates/expression_spec.rb +82 -0
- data/spec/flipper/instrumentation/log_subscriber_spec.rb +1 -0
- data/spec/flipper/instrumentation/statsd_subscriber_spec.rb +1 -1
- data/spec/flipper/middleware/memoizer_spec.rb +41 -11
- data/spec/flipper/model/active_record_spec.rb +11 -0
- data/spec/flipper/poller_spec.rb +347 -4
- data/spec/flipper_integration_spec.rb +133 -0
- data/spec/flipper_spec.rb +7 -2
- data/spec/spec_helper.rb +15 -5
- data/test_rails/generators/flipper/setup_generator_test.rb +5 -0
- data/test_rails/generators/flipper/update_generator_test.rb +1 -1
- data/test_rails/helper.rb +3 -0
- metadata +17 -111
- data/lib/flipper/expressions/duration.rb +0 -28
- 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 "
|
|
37
|
+
it "warns for unknown feature" do
|
|
32
38
|
expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
|
|
33
39
|
end
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
context "#get_multi" do
|
|
37
|
-
it "
|
|
43
|
+
it "warns for unknown feature" do
|
|
38
44
|
expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
|
|
39
45
|
end
|
|
40
46
|
end
|
|
47
|
+
|
|
48
|
+
context "#add" do
|
|
49
|
+
it "warns for unknown feature" do
|
|
50
|
+
expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
41
53
|
end
|
|
42
54
|
|
|
43
55
|
context "handler = Block" do
|
|
@@ -48,17 +60,63 @@ RSpec.describe Flipper::Adapters::Strict do
|
|
|
48
60
|
|
|
49
61
|
|
|
50
62
|
context "#get" do
|
|
51
|
-
it "
|
|
63
|
+
it "calls block for unknown feature" do
|
|
52
64
|
subject.get(feature)
|
|
53
65
|
expect(unknown_features).to eq(["unknown"])
|
|
54
66
|
end
|
|
55
67
|
end
|
|
56
68
|
|
|
57
69
|
context "#get_multi" do
|
|
58
|
-
it "
|
|
70
|
+
it "calls block for unknown feature" do
|
|
59
71
|
subject.get_multi([flipper[:foo], flipper[:bar]])
|
|
60
72
|
expect(unknown_features).to eq(["foo", "bar"])
|
|
61
73
|
end
|
|
62
74
|
end
|
|
75
|
+
|
|
76
|
+
context "#add" do
|
|
77
|
+
it "calls block for unknown feature" do
|
|
78
|
+
subject.add(feature)
|
|
79
|
+
expect(unknown_features).to eq(["unknown"])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe ".with_sync_mode" do
|
|
85
|
+
subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
|
|
86
|
+
|
|
87
|
+
it "bypasses strict checks for add" do
|
|
88
|
+
described_class.with_sync_mode do
|
|
89
|
+
expect { subject.add(feature) }.not_to raise_error
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "bypasses strict checks for get" do
|
|
94
|
+
described_class.with_sync_mode do
|
|
95
|
+
expect { subject.get(feature) }.not_to raise_error
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "bypasses strict checks for get_multi" do
|
|
100
|
+
described_class.with_sync_mode do
|
|
101
|
+
expect { subject.get_multi([feature]) }.not_to raise_error
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "restores previous sync mode after block" do
|
|
106
|
+
described_class.with_sync_mode do
|
|
107
|
+
# inside sync mode
|
|
108
|
+
end
|
|
109
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it "restores previous sync mode even on error" do
|
|
113
|
+
begin
|
|
114
|
+
described_class.with_sync_mode do
|
|
115
|
+
raise "boom"
|
|
116
|
+
end
|
|
117
|
+
rescue RuntimeError
|
|
118
|
+
end
|
|
119
|
+
expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
|
|
120
|
+
end
|
|
63
121
|
end
|
|
64
122
|
end
|
|
@@ -105,6 +105,18 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
|
|
|
105
105
|
expect_no_enable_or_disable
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
+
it "updates expression when remote conditionally enabled but expression is nil" do
|
|
109
|
+
remote = Flipper::GateValues.new(expression: nil, actors: Set["1"])
|
|
110
|
+
feature.enable_expression(plan_expression)
|
|
111
|
+
feature.enable_actor(Flipper::Actor.new("1"))
|
|
112
|
+
adapter.reset
|
|
113
|
+
|
|
114
|
+
described_class.new(feature, feature.gate_values, remote).call
|
|
115
|
+
|
|
116
|
+
expect(feature.expression_value).to eq(nil)
|
|
117
|
+
expect_only_disable
|
|
118
|
+
end
|
|
119
|
+
|
|
108
120
|
it "adds remotely added actors" do
|
|
109
121
|
remote = Flipper::GateValues.new(actors: Set["1", "2"])
|
|
110
122
|
feature.enable_actor(Flipper::Actor.new("1"))
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "flipper/adapters/memory"
|
|
2
|
+
require "flipper/adapters/actor_limit"
|
|
2
3
|
require "flipper/instrumenters/memory"
|
|
3
4
|
require "flipper/adapters/sync/synchronizer"
|
|
4
5
|
|
|
@@ -84,5 +85,91 @@ RSpec.describe Flipper::Adapters::Sync::Synchronizer do
|
|
|
84
85
|
|
|
85
86
|
expect(local_flipper.features.map(&:key)).to eq([])
|
|
86
87
|
end
|
|
88
|
+
|
|
89
|
+
it 'emits feature_operation.flipper events when syncing' do
|
|
90
|
+
remote_flipper.enable(:search)
|
|
91
|
+
|
|
92
|
+
subject.call
|
|
93
|
+
|
|
94
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
95
|
+
enable_events = events.select { |e| e.payload[:operation] == :enable }
|
|
96
|
+
expect(enable_events).not_to be_empty
|
|
97
|
+
|
|
98
|
+
feature_names = enable_events.map { |e| e.payload[:feature_name].to_s }
|
|
99
|
+
expect(feature_names).to include("search")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'emits feature_operation.flipper events when adding features' do
|
|
103
|
+
remote_flipper.add(:new_feature)
|
|
104
|
+
|
|
105
|
+
subject.call
|
|
106
|
+
|
|
107
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
108
|
+
add_events = events.select { |e| e.payload[:operation] == :add }
|
|
109
|
+
expect(add_events).not_to be_empty
|
|
110
|
+
|
|
111
|
+
feature_names = add_events.map { |e| e.payload[:feature_name].to_s }
|
|
112
|
+
expect(feature_names).to include("new_feature")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'emits feature_operation.flipper events when removing features' do
|
|
116
|
+
local_flipper.add(:old_feature)
|
|
117
|
+
|
|
118
|
+
subject.call
|
|
119
|
+
|
|
120
|
+
events = instrumenter.events_by_name("feature_operation.flipper")
|
|
121
|
+
remove_events = events.select { |e| e.payload[:operation] == :remove }
|
|
122
|
+
expect(remove_events).not_to be_empty
|
|
123
|
+
|
|
124
|
+
feature_names = remove_events.map { |e| e.payload[:feature_name].to_s }
|
|
125
|
+
expect(feature_names).to include("old_feature")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
context 'with ActorLimit adapter wrapping local' do
|
|
130
|
+
let(:limit) { 10 }
|
|
131
|
+
let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) }
|
|
132
|
+
let(:limited_local_flipper) { Flipper.new(limited_local) }
|
|
133
|
+
|
|
134
|
+
subject { described_class.new(limited_local, remote, instrumenter: instrumenter) }
|
|
135
|
+
|
|
136
|
+
it 'syncs actors even when remote has more actors than local limit' do
|
|
137
|
+
# Remote has more actors than local limit allows
|
|
138
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
139
|
+
|
|
140
|
+
# This should NOT raise - sync should bypass actor limits
|
|
141
|
+
expect { subject.call }.not_to raise_error
|
|
142
|
+
|
|
143
|
+
# All actors should be synced
|
|
144
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'syncs new actors added to remote after initial sync' do
|
|
148
|
+
# Initial state: remote has 20 actors, local limit is 10
|
|
149
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
150
|
+
|
|
151
|
+
# First sync - should work despite exceeding limit
|
|
152
|
+
subject.call
|
|
153
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(20)
|
|
154
|
+
|
|
155
|
+
# Add a 21st actor to remote (simulating Cloud adding a new actor)
|
|
156
|
+
remote_flipper[:search].enable_actor Flipper::Actor.new("User;20")
|
|
157
|
+
|
|
158
|
+
# Sync again - should pick up the new actor
|
|
159
|
+
expect { subject.call }.not_to raise_error
|
|
160
|
+
expect(limited_local_flipper[:search].actors_value.size).to eq(21)
|
|
161
|
+
expect(limited_local_flipper[:search].actors_value).to include("User;20")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'still enforces limit for direct enable operations' do
|
|
165
|
+
# First sync 20 actors from remote
|
|
166
|
+
20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
|
|
167
|
+
subject.call
|
|
168
|
+
|
|
169
|
+
# Direct enable should still fail because we're over limit
|
|
170
|
+
expect {
|
|
171
|
+
limited_local_flipper[:search].enable_actor Flipper::Actor.new("User;new")
|
|
172
|
+
}.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
|
|
173
|
+
end
|
|
87
174
|
end
|
|
88
175
|
end
|
|
@@ -197,4 +197,17 @@ RSpec.describe Flipper::Adapters::Sync do
|
|
|
197
197
|
expect(remote_adapter).to receive(:get_all).and_raise(exception)
|
|
198
198
|
expect { subject.get_all }.not_to raise_error
|
|
199
199
|
end
|
|
200
|
+
|
|
201
|
+
describe '#adapter_stack' do
|
|
202
|
+
it 'returns the tree representation' do
|
|
203
|
+
expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'shows nested adapters in the tree' do
|
|
207
|
+
memory = Flipper::Adapters::Memory.new
|
|
208
|
+
strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
|
|
209
|
+
adapter = described_class.new(memory, strict, interval: 1)
|
|
210
|
+
expect(adapter.adapter_stack).to eq("sync(local: memory, remote: strict -> memory)")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
200
213
|
end
|
data/spec/flipper/cli_spec.rb
CHANGED
|
@@ -147,6 +147,57 @@ RSpec.describe Flipper::CLI do
|
|
|
147
147
|
it { should have_attributes(status: 1, stderr: /invalid option: --nope/) }
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
describe "export" do
|
|
151
|
+
before do
|
|
152
|
+
Flipper.enable :search
|
|
153
|
+
Flipper.disable :analytics
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it "outputs valid JSON export" do
|
|
157
|
+
expect(subject).to have_attributes(status: 0)
|
|
158
|
+
data = JSON.parse(subject.stdout)
|
|
159
|
+
expect(data["version"]).to eq(1)
|
|
160
|
+
expect(data["features"]).to have_key("search")
|
|
161
|
+
expect(data["features"]).to have_key("analytics")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
describe "cloud" do
|
|
166
|
+
it "shows help when no subcommand given" do
|
|
167
|
+
expect(subject).to have_attributes(status: 0, stdout: /migrate/)
|
|
168
|
+
expect(subject.stdout).to match(/push/)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe "cloud migrate" do
|
|
173
|
+
before do
|
|
174
|
+
Flipper.enable :search
|
|
175
|
+
require 'flipper/cloud/migrate'
|
|
176
|
+
allow(Flipper::Cloud).to receive(:migrate).and_return(
|
|
177
|
+
Flipper::Cloud::MigrateResult.new(code: 200, url: "https://www.flippercloud.io/cloud/setup/abc123")
|
|
178
|
+
)
|
|
179
|
+
allow(cli).to receive(:system)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
it "prints the cloud URL" do
|
|
183
|
+
expect(subject).to have_attributes(status: 0, stdout: /flippercloud\.io/)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "cloud push test-token" do
|
|
188
|
+
before do
|
|
189
|
+
Flipper.enable :search
|
|
190
|
+
require 'flipper/cloud/migrate'
|
|
191
|
+
allow(Flipper::Cloud).to receive(:push).and_return(
|
|
192
|
+
Flipper::Cloud::MigrateResult.new(code: 204, url: nil)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "prints success message" do
|
|
197
|
+
expect(subject).to have_attributes(status: 0, stdout: /Successfully pushed/)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
150
201
|
describe "show foo" do
|
|
151
202
|
context "boolean" do
|
|
152
203
|
before { Flipper.enable :foo }
|
|
@@ -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
|
|