flipper 1.3.4 → 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 (66) 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/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 +3 -2
  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/instrumentation/log_subscriber.rb +1 -1
  40. data/lib/flipper/poller.rb +49 -8
  41. data/lib/flipper/version.rb +1 -1
  42. data/lib/flipper.rb +17 -1
  43. data/spec/flipper/adapter_spec.rb +20 -0
  44. data/spec/flipper/adapters/actor_limit_spec.rb +55 -0
  45. data/spec/flipper/adapters/dual_write_spec.rb +13 -0
  46. data/spec/flipper/adapters/failover_spec.rb +12 -0
  47. data/spec/flipper/adapters/http_spec.rb +240 -0
  48. data/spec/flipper/adapters/strict_spec.rb +62 -4
  49. data/spec/flipper/adapters/sync/feature_synchronizer_spec.rb +12 -0
  50. data/spec/flipper/adapters/sync/synchronizer_spec.rb +87 -0
  51. data/spec/flipper/adapters/sync_spec.rb +13 -0
  52. data/spec/flipper/cli_spec.rb +51 -0
  53. data/spec/flipper/cloud/middleware_spec.rb +1 -1
  54. data/spec/flipper/cloud/migrate_spec.rb +160 -0
  55. data/spec/flipper/cloud/telemetry_spec.rb +1 -1
  56. data/spec/flipper/engine_spec.rb +2 -2
  57. data/spec/flipper/expressions/time_spec.rb +16 -0
  58. data/spec/flipper/gates/expression_spec.rb +82 -0
  59. data/spec/flipper/middleware/memoizer_spec.rb +37 -6
  60. data/spec/flipper/poller_spec.rb +347 -4
  61. data/spec/flipper_integration_spec.rb +133 -0
  62. data/spec/flipper_spec.rb +5 -0
  63. data/spec/spec_helper.rb +7 -0
  64. metadata +18 -112
  65. data/lib/flipper/expressions/duration.rb +0 -28
  66. data/spec/flipper/expressions/duration_spec.rb +0 -43
@@ -21,6 +21,12 @@ RSpec.describe Flipper::Adapters::Strict do
21
21
  expect { subject.get_multi([feature]) }.to raise_error(Flipper::Adapters::Strict::NotFound)
22
22
  end
23
23
  end
24
+
25
+ context "#add" do
26
+ it "raises an error for unknown feature" do
27
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
28
+ end
29
+ end
24
30
  end
25
31
  end
26
32
 
@@ -28,16 +34,22 @@ RSpec.describe Flipper::Adapters::Strict do
28
34
  subject { described_class.new(Flipper::Adapters::Memory.new, :warn) }
29
35
 
30
36
  context "#get" do
31
- it "raises an error for unknown feature" do
37
+ it "warns for unknown feature" do
32
38
  expect(capture_output { subject.get(feature) }).to match(/Could not find feature "unknown"/)
33
39
  end
34
40
  end
35
41
 
36
42
  context "#get_multi" do
37
- it "raises an error for unknown feature" do
43
+ it "warns for unknown feature" do
38
44
  expect(capture_output { subject.get_multi([feature]) }).to match(/Could not find feature "unknown"/)
39
45
  end
40
46
  end
47
+
48
+ context "#add" do
49
+ it "warns for unknown feature" do
50
+ expect(capture_output { subject.add(feature) }).to match(/Could not find feature "unknown"/)
51
+ end
52
+ end
41
53
  end
42
54
 
43
55
  context "handler = Block" do
@@ -48,17 +60,63 @@ RSpec.describe Flipper::Adapters::Strict do
48
60
 
49
61
 
50
62
  context "#get" do
51
- it "raises an error for unknown feature" do
63
+ it "calls block for unknown feature" do
52
64
  subject.get(feature)
53
65
  expect(unknown_features).to eq(["unknown"])
54
66
  end
55
67
  end
56
68
 
57
69
  context "#get_multi" do
58
- it "raises an error for unknown feature" do
70
+ it "calls block for unknown feature" do
59
71
  subject.get_multi([flipper[:foo], flipper[:bar]])
60
72
  expect(unknown_features).to eq(["foo", "bar"])
61
73
  end
62
74
  end
75
+
76
+ context "#add" do
77
+ it "calls block for unknown feature" do
78
+ subject.add(feature)
79
+ expect(unknown_features).to eq(["unknown"])
80
+ end
81
+ end
82
+ end
83
+
84
+ describe ".with_sync_mode" do
85
+ subject { described_class.new(Flipper::Adapters::Memory.new, :raise) }
86
+
87
+ it "bypasses strict checks for add" do
88
+ described_class.with_sync_mode do
89
+ expect { subject.add(feature) }.not_to raise_error
90
+ end
91
+ end
92
+
93
+ it "bypasses strict checks for get" do
94
+ described_class.with_sync_mode do
95
+ expect { subject.get(feature) }.not_to raise_error
96
+ end
97
+ end
98
+
99
+ it "bypasses strict checks for get_multi" do
100
+ described_class.with_sync_mode do
101
+ expect { subject.get_multi([feature]) }.not_to raise_error
102
+ end
103
+ end
104
+
105
+ it "restores previous sync mode after block" do
106
+ described_class.with_sync_mode do
107
+ # inside sync mode
108
+ end
109
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
110
+ end
111
+
112
+ it "restores previous sync mode even on error" do
113
+ begin
114
+ described_class.with_sync_mode do
115
+ raise "boom"
116
+ end
117
+ rescue RuntimeError
118
+ end
119
+ expect { subject.add(feature) }.to raise_error(Flipper::Adapters::Strict::NotFound)
120
+ end
63
121
  end
64
122
  end
@@ -105,6 +105,18 @@ RSpec.describe Flipper::Adapters::Sync::FeatureSynchronizer do
105
105
  expect_no_enable_or_disable
106
106
  end
107
107
 
108
+ it "updates expression when remote conditionally enabled but expression is nil" do
109
+ remote = Flipper::GateValues.new(expression: nil, actors: Set["1"])
110
+ feature.enable_expression(plan_expression)
111
+ feature.enable_actor(Flipper::Actor.new("1"))
112
+ adapter.reset
113
+
114
+ described_class.new(feature, feature.gate_values, remote).call
115
+
116
+ expect(feature.expression_value).to eq(nil)
117
+ expect_only_disable
118
+ end
119
+
108
120
  it "adds remotely added actors" do
109
121
  remote = Flipper::GateValues.new(actors: Set["1", "2"])
110
122
  feature.enable_actor(Flipper::Actor.new("1"))
@@ -1,4 +1,5 @@
1
1
  require "flipper/adapters/memory"
2
+ require "flipper/adapters/actor_limit"
2
3
  require "flipper/instrumenters/memory"
3
4
  require "flipper/adapters/sync/synchronizer"
4
5
 
@@ -84,5 +85,91 @@ RSpec.describe Flipper::Adapters::Sync::Synchronizer do
84
85
 
85
86
  expect(local_flipper.features.map(&:key)).to eq([])
86
87
  end
88
+
89
+ it 'emits feature_operation.flipper events when syncing' do
90
+ remote_flipper.enable(:search)
91
+
92
+ subject.call
93
+
94
+ events = instrumenter.events_by_name("feature_operation.flipper")
95
+ enable_events = events.select { |e| e.payload[:operation] == :enable }
96
+ expect(enable_events).not_to be_empty
97
+
98
+ feature_names = enable_events.map { |e| e.payload[:feature_name].to_s }
99
+ expect(feature_names).to include("search")
100
+ end
101
+
102
+ it 'emits feature_operation.flipper events when adding features' do
103
+ remote_flipper.add(:new_feature)
104
+
105
+ subject.call
106
+
107
+ events = instrumenter.events_by_name("feature_operation.flipper")
108
+ add_events = events.select { |e| e.payload[:operation] == :add }
109
+ expect(add_events).not_to be_empty
110
+
111
+ feature_names = add_events.map { |e| e.payload[:feature_name].to_s }
112
+ expect(feature_names).to include("new_feature")
113
+ end
114
+
115
+ it 'emits feature_operation.flipper events when removing features' do
116
+ local_flipper.add(:old_feature)
117
+
118
+ subject.call
119
+
120
+ events = instrumenter.events_by_name("feature_operation.flipper")
121
+ remove_events = events.select { |e| e.payload[:operation] == :remove }
122
+ expect(remove_events).not_to be_empty
123
+
124
+ feature_names = remove_events.map { |e| e.payload[:feature_name].to_s }
125
+ expect(feature_names).to include("old_feature")
126
+ end
127
+ end
128
+
129
+ context 'with ActorLimit adapter wrapping local' do
130
+ let(:limit) { 10 }
131
+ let(:limited_local) { Flipper::Adapters::ActorLimit.new(local, limit) }
132
+ let(:limited_local_flipper) { Flipper.new(limited_local) }
133
+
134
+ subject { described_class.new(limited_local, remote, instrumenter: instrumenter) }
135
+
136
+ it 'syncs actors even when remote has more actors than local limit' do
137
+ # Remote has more actors than local limit allows
138
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
139
+
140
+ # This should NOT raise - sync should bypass actor limits
141
+ expect { subject.call }.not_to raise_error
142
+
143
+ # All actors should be synced
144
+ expect(limited_local_flipper[:search].actors_value.size).to eq(20)
145
+ end
146
+
147
+ it 'syncs new actors added to remote after initial sync' do
148
+ # Initial state: remote has 20 actors, local limit is 10
149
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
150
+
151
+ # First sync - should work despite exceeding limit
152
+ subject.call
153
+ expect(limited_local_flipper[:search].actors_value.size).to eq(20)
154
+
155
+ # Add a 21st actor to remote (simulating Cloud adding a new actor)
156
+ remote_flipper[:search].enable_actor Flipper::Actor.new("User;20")
157
+
158
+ # Sync again - should pick up the new actor
159
+ expect { subject.call }.not_to raise_error
160
+ expect(limited_local_flipper[:search].actors_value.size).to eq(21)
161
+ expect(limited_local_flipper[:search].actors_value).to include("User;20")
162
+ end
163
+
164
+ it 'still enforces limit for direct enable operations' do
165
+ # First sync 20 actors from remote
166
+ 20.times { |i| remote_flipper[:search].enable_actor Flipper::Actor.new("User;#{i}") }
167
+ subject.call
168
+
169
+ # Direct enable should still fail because we're over limit
170
+ expect {
171
+ limited_local_flipper[:search].enable_actor Flipper::Actor.new("User;new")
172
+ }.to raise_error(Flipper::Adapters::ActorLimit::LimitExceeded)
173
+ end
87
174
  end
88
175
  end
@@ -197,4 +197,17 @@ RSpec.describe Flipper::Adapters::Sync do
197
197
  expect(remote_adapter).to receive(:get_all).and_raise(exception)
198
198
  expect { subject.get_all }.not_to raise_error
199
199
  end
200
+
201
+ describe '#adapter_stack' do
202
+ it 'returns the tree representation' do
203
+ expect(subject.adapter_stack).to eq("sync(local: operation_logger -> memory, remote: operation_logger -> memory)")
204
+ end
205
+
206
+ it 'shows nested adapters in the tree' do
207
+ memory = Flipper::Adapters::Memory.new
208
+ strict = Flipper::Adapters::Strict.new(Flipper::Adapters::Memory.new)
209
+ adapter = described_class.new(memory, strict, interval: 1)
210
+ expect(adapter.adapter_stack).to eq("sync(local: memory, remote: strict -> memory)")
211
+ end
212
+ end
200
213
  end
@@ -147,6 +147,57 @@ RSpec.describe Flipper::CLI do
147
147
  it { should have_attributes(status: 1, stderr: /invalid option: --nope/) }
148
148
  end
149
149
 
150
+ describe "export" do
151
+ before do
152
+ Flipper.enable :search
153
+ Flipper.disable :analytics
154
+ end
155
+
156
+ it "outputs valid JSON export" do
157
+ expect(subject).to have_attributes(status: 0)
158
+ data = JSON.parse(subject.stdout)
159
+ expect(data["version"]).to eq(1)
160
+ expect(data["features"]).to have_key("search")
161
+ expect(data["features"]).to have_key("analytics")
162
+ end
163
+ end
164
+
165
+ describe "cloud" do
166
+ it "shows help when no subcommand given" do
167
+ expect(subject).to have_attributes(status: 0, stdout: /migrate/)
168
+ expect(subject.stdout).to match(/push/)
169
+ end
170
+ end
171
+
172
+ describe "cloud migrate" do
173
+ before do
174
+ Flipper.enable :search
175
+ require 'flipper/cloud/migrate'
176
+ allow(Flipper::Cloud).to receive(:migrate).and_return(
177
+ Flipper::Cloud::MigrateResult.new(code: 200, url: "https://www.flippercloud.io/cloud/setup/abc123")
178
+ )
179
+ allow(cli).to receive(:system)
180
+ end
181
+
182
+ it "prints the cloud URL" do
183
+ expect(subject).to have_attributes(status: 0, stdout: /flippercloud\.io/)
184
+ end
185
+ end
186
+
187
+ describe "cloud push test-token" do
188
+ before do
189
+ Flipper.enable :search
190
+ require 'flipper/cloud/migrate'
191
+ allow(Flipper::Cloud).to receive(:push).and_return(
192
+ Flipper::Cloud::MigrateResult.new(code: 204, url: nil)
193
+ )
194
+ end
195
+
196
+ it "prints success message" do
197
+ expect(subject).to have_attributes(status: 0, stdout: /Successfully pushed/)
198
+ end
199
+ end
200
+
150
201
  describe "show foo" do
151
202
  context "boolean" do
152
203
  before { Flipper.enable :foo }
@@ -274,7 +274,7 @@ RSpec.describe Flipper::Cloud::Middleware do
274
274
  private
275
275
 
276
276
  def stub_request_for_token(token, status: 200)
277
- stub = stub_request(:get, "https://www.flippercloud.io/adapter/features?exclude_gate_names=true").
277
+ stub = stub_request(:get, %r{\Ahttps://www\.flippercloud\.io/adapter/features\?(?=.*\bexclude_gate_names=true\b)(?=.*\b_cb=\d+\b)}).
278
278
  with({
279
279
  headers: {
280
280
  'flipper-cloud-token' => token,
@@ -0,0 +1,160 @@
1
+ require "flipper/cloud/migrate"
2
+ require "flipper/typecast"
3
+ require "webmock/rspec"
4
+
5
+ RSpec.describe Flipper::Cloud, ".migrate" do
6
+ let(:flipper) { Flipper.new(Flipper::Adapters::Memory.new) }
7
+
8
+ before do
9
+ flipper.enable :search
10
+ flipper.disable :analytics
11
+ flipper.enable_percentage_of_actors :checkout, 50
12
+ end
13
+
14
+ around do |example|
15
+ original = ENV["FLIPPER_CLOUD_URL"]
16
+ ENV["FLIPPER_CLOUD_URL"] = "http://localhost:5555"
17
+ example.run
18
+ ensure
19
+ ENV["FLIPPER_CLOUD_URL"] = original
20
+ end
21
+
22
+ def decompress_request_body
23
+ raw = WebMock::RequestRegistry.instance.requested_signatures.hash.keys.last.body
24
+ JSON.parse(Flipper::Typecast.from_gzip(raw))
25
+ end
26
+
27
+ describe ".migrate" do
28
+ it "returns a MigrateResult with code and url on success" do
29
+ stub_request(:post, "http://localhost:5555/api/migrate")
30
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}', headers: {"Content-Type" => "application/json"})
31
+
32
+ result = Flipper::Cloud.migrate(flipper)
33
+
34
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
35
+ expect(result.code).to eq(200)
36
+ expect(result.url).to eq("http://localhost:5555/cloud/setup/abc123")
37
+ end
38
+
39
+ it "sends export data and metadata in the request body" do
40
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
41
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc123"}')
42
+
43
+ Flipper::Cloud.migrate(flipper, app_name: "MyApp")
44
+
45
+ expect(stub).to have_been_requested
46
+ body = decompress_request_body
47
+ expect(body["metadata"]["app_name"]).to eq("MyApp")
48
+ expect(body["export"]["version"]).to eq(1)
49
+ expect(body["export"]["features"]).to have_key("search")
50
+ end
51
+
52
+ it "sends gzip-compressed request body" do
53
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
54
+ .with(headers: {"content-encoding" => "gzip"})
55
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
56
+
57
+ Flipper::Cloud.migrate(flipper)
58
+
59
+ expect(stub).to have_been_requested
60
+ body = decompress_request_body
61
+ expect(body["export"]["features"]).to have_key("search")
62
+ end
63
+
64
+ it "handles error responses" do
65
+ stub_request(:post, "http://localhost:5555/api/migrate")
66
+ .to_return(status: 500, body: '{"error":"Internal Server Error"}')
67
+
68
+ result = Flipper::Cloud.migrate(flipper)
69
+
70
+ expect(result.code).to eq(500)
71
+ expect(result.url).to be_nil
72
+ end
73
+
74
+ it "includes error message from response body" do
75
+ stub_request(:post, "http://localhost:5555/api/migrate")
76
+ .to_return(status: 422, body: '{"error":"Invalid export format"}')
77
+
78
+ result = Flipper::Cloud.migrate(flipper)
79
+
80
+ expect(result.code).to eq(422)
81
+ expect(result.message).to eq("Invalid export format")
82
+ end
83
+
84
+ it "uses FLIPPER_CLOUD_URL environment variable" do
85
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
86
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
87
+
88
+ Flipper::Cloud.migrate(flipper)
89
+
90
+ expect(stub).to have_been_requested
91
+ end
92
+
93
+ it "sends content-type and accept headers" do
94
+ stub = stub_request(:post, "http://localhost:5555/api/migrate")
95
+ .with(headers: {
96
+ "content-type" => "application/json",
97
+ "accept" => "application/json",
98
+ })
99
+ .to_return(status: 200, body: '{"url":"http://localhost:5555/cloud/setup/abc"}')
100
+
101
+ Flipper::Cloud.migrate(flipper)
102
+
103
+ expect(stub).to have_been_requested
104
+ end
105
+ end
106
+
107
+ describe ".push" do
108
+ it "returns a MigrateResult with code on success" do
109
+ stub_request(:post, "http://localhost:5555/adapter/import")
110
+ .to_return(status: 204, body: "")
111
+
112
+ result = Flipper::Cloud.push("test-token", flipper)
113
+
114
+ expect(result).to be_a(Flipper::Cloud::MigrateResult)
115
+ expect(result.code).to eq(204)
116
+ end
117
+
118
+ it "sends the token as a header" do
119
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
120
+ .with(headers: {"flipper-cloud-token" => "test-token"})
121
+ .to_return(status: 204, body: "")
122
+
123
+ Flipper::Cloud.push("test-token", flipper)
124
+
125
+ expect(stub).to have_been_requested
126
+ end
127
+
128
+ it "sends gzip-compressed export contents as the body" do
129
+ stub = stub_request(:post, "http://localhost:5555/adapter/import")
130
+ .with(headers: {"content-encoding" => "gzip"})
131
+ .to_return(status: 204, body: "")
132
+
133
+ Flipper::Cloud.push("test-token", flipper)
134
+
135
+ expect(stub).to have_been_requested
136
+ body = decompress_request_body
137
+ expect(body["version"]).to eq(1)
138
+ expect(body["features"]).to have_key("search")
139
+ end
140
+
141
+ it "handles error responses" do
142
+ stub_request(:post, "http://localhost:5555/adapter/import")
143
+ .to_return(status: 401, body: '{"error":"Unauthorized"}')
144
+
145
+ result = Flipper::Cloud.push("bad-token", flipper)
146
+
147
+ expect(result.code).to eq(401)
148
+ end
149
+
150
+ it "includes error message from response body" do
151
+ stub_request(:post, "http://localhost:5555/adapter/import")
152
+ .to_return(status: 401, body: '{"error":"Invalid token"}')
153
+
154
+ result = Flipper::Cloud.push("bad-token", flipper)
155
+
156
+ expect(result.code).to eq(401)
157
+ expect(result.message).to eq("Invalid token")
158
+ end
159
+ end
160
+ end
@@ -122,7 +122,7 @@ RSpec.describe Flipper::Cloud::Telemetry do
122
122
  # Check the conig interval and the timer interval.
123
123
  expect(telemetry.interval).to eq(120)
124
124
  expect(telemetry.timer.execution_interval).to eq(120)
125
- expect(stub).to have_been_requested.times(5)
125
+ expect(stub).to have_been_requested.at_least_times(5)
126
126
  end
127
127
 
128
128
  it "doesn't try to update telemetry interval from error if not response error" do
@@ -263,10 +263,10 @@ RSpec.describe Flipper::Engine do
263
263
  Flipper::Cloud::MessageVerifier.new(secret: "").header(signature, timestamp)
264
264
  }
265
265
 
266
- it "configures webhook app" do
266
+ it "configures webhook app and uses cache busting" do
267
267
  silence { application.initialize! }
268
268
 
269
- 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({
270
270
  headers: { "flipper-cloud-token" => ENV["FLIPPER_CLOUD_TOKEN"] },
271
271
  }).to_return(status: 200, body: JSON.generate({ features: {} }), headers: {})
272
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
@@ -62,6 +62,30 @@ RSpec.describe Flipper::Gates::Expression do
62
62
  end
63
63
  end
64
64
 
65
+ context 'for actor in context' do
66
+ it 'passes actor to expression context' do
67
+ actor = Flipper::Actor.new("User;1", {type: "User"})
68
+ wrapped_actor = Flipper::Types::Actor.new(actor)
69
+ expression = Flipper.property(:flipper_id).eq("User;1")
70
+ ctx = Flipper::FeatureCheckContext.new(
71
+ feature_name: feature_name,
72
+ values: Flipper::GateValues.new(expression: expression.value),
73
+ actors: [wrapped_actor]
74
+ )
75
+ expect(subject.open?(ctx)).to be(true)
76
+ end
77
+
78
+ it 'passes nil actor when no actors provided' do
79
+ expression = Flipper.boolean(true).eq(true)
80
+ ctx = Flipper::FeatureCheckContext.new(
81
+ feature_name: feature_name,
82
+ values: Flipper::GateValues.new(expression: expression.value),
83
+ actors: nil
84
+ )
85
+ expect(subject.open?(ctx)).to be(true)
86
+ end
87
+ end
88
+
65
89
  context 'for properties that have symbol keys' do
66
90
  it 'returns true when expression evalutes to true' do
67
91
  expression = Flipper.property(:type).eq("User")
@@ -75,6 +99,64 @@ RSpec.describe Flipper::Gates::Expression do
75
99
  expect(subject.open?(context)).to be(false)
76
100
  end
77
101
  end
102
+
103
+ context 'for time-based expressions' do
104
+ it 'enables when now is past a scheduled epoch' do
105
+ past_epoch = Time.now.to_i - 86_400
106
+ expression = Flipper.now.gte(Flipper.time(past_epoch))
107
+ expect(subject.open?(context(expression.value))).to be(true)
108
+ end
109
+
110
+ it 'does not enable when now is before a future epoch' do
111
+ future_epoch = Time.now.to_i + 86_400
112
+ expression = Flipper.now.gte(Flipper.time(future_epoch))
113
+ expect(subject.open?(context(expression.value))).to be(false)
114
+ end
115
+
116
+ it 'enables when now is past a scheduled datetime' do
117
+ past_time = (Time.now.utc - 86_400).iso8601
118
+ expression = Flipper.now.gte(Flipper.time(past_time))
119
+ expect(subject.open?(context(expression.value))).to be(true)
120
+ end
121
+
122
+ it 'does not enable when now is before a future datetime' do
123
+ future_time = (Time.now.utc + 86_400).iso8601
124
+ expression = Flipper.now.gte(Flipper.time(future_time))
125
+ expect(subject.open?(context(expression.value))).to be(false)
126
+ end
127
+
128
+ it 'enables expiring features with lt' do
129
+ future_time = (Time.now.utc + 86_400).iso8601
130
+ expression = Flipper.now.lt(Flipper.time(future_time))
131
+ expect(subject.open?(context(expression.value))).to be(true)
132
+ end
133
+
134
+ it 'disables expired features with lt' do
135
+ past_time = (Time.now.utc - 86_400).iso8601
136
+ expression = Flipper.now.lt(Flipper.time(past_time))
137
+ expect(subject.open?(context(expression.value))).to be(false)
138
+ end
139
+
140
+ it 'enables within a time window using all' do
141
+ start_time = (Time.now.utc - 86_400).iso8601
142
+ end_time = (Time.now.utc + 86_400).iso8601
143
+ expression = Flipper.all(
144
+ Flipper.now.gte(Flipper.time(start_time)),
145
+ Flipper.now.lt(Flipper.time(end_time))
146
+ )
147
+ expect(subject.open?(context(expression.value))).to be(true)
148
+ end
149
+
150
+ it 'does not enable outside a time window' do
151
+ start_time = (Time.now.utc + 86_400).iso8601
152
+ end_time = (Time.now.utc + 172_800).iso8601
153
+ expression = Flipper.all(
154
+ Flipper.now.gte(Flipper.time(start_time)),
155
+ Flipper.now.lt(Flipper.time(end_time))
156
+ )
157
+ expect(subject.open?(context(expression.value))).to be(false)
158
+ end
159
+ end
78
160
  end
79
161
 
80
162
  describe '#protects?' do
@@ -2,6 +2,8 @@ require 'rack/test'
2
2
  require 'active_support/cache'
3
3
  require 'flipper/adapters/active_support_cache_store'
4
4
  require 'flipper/adapters/operation_logger'
5
+ require 'flipper/adapters/actor_limit'
6
+ require 'flipper/adapters/sync'
5
7
 
6
8
  RSpec.describe Flipper::Middleware::Memoizer do
7
9
  include Rack::Test::Methods
@@ -470,18 +472,47 @@ RSpec.describe Flipper::Middleware::Memoizer do
470
472
 
471
473
  get '/', {}, 'flipper' => flipper
472
474
  expect(logged_cached.count(:get_all)).to be(1)
473
- expect(logged_memory.count(:features)).to be(1)
474
- expect(logged_memory.count(:get_multi)).to be(1)
475
+ expect(logged_memory.count(:get_all)).to be(1)
475
476
 
476
477
  get '/', {}, 'flipper' => flipper
477
478
  expect(logged_cached.count(:get_all)).to be(2)
478
- expect(logged_memory.count(:features)).to be(1)
479
- expect(logged_memory.count(:get_multi)).to be(1)
479
+ expect(logged_memory.count(:get_all)).to be(1)
480
480
 
481
481
  get '/', {}, 'flipper' => flipper
482
482
  expect(logged_cached.count(:get_all)).to be(3)
483
- expect(logged_memory.count(:features)).to be(1)
484
- expect(logged_memory.count(:get_multi)).to be(1)
483
+ expect(logged_memory.count(:get_all)).to be(1)
484
+ end
485
+ end
486
+
487
+ context 'with preload:true and Sync adapter wrapped with ActorLimit' do
488
+ it 'preloads even when remote has more actors than local limit' do
489
+ local = Flipper::Adapters::Memory.new
490
+ remote = Flipper::Adapters::Memory.new
491
+ remote_flipper = Flipper.new(remote)
492
+
493
+ # Remote has more actors than limit allows (actor-only enables, not boolean)
494
+ 10.times { |i| remote_flipper[:stats].enable_actor Flipper::Actor.new("User;#{i}") }
495
+
496
+ # Sync adapter will sync from remote to local, then ActorLimit wraps it
497
+ # Use interval: 0 to force sync on every call
498
+ sync_adapter = Flipper::Adapters::Sync.new(local, remote, interval: 0)
499
+ limited_adapter = Flipper::Adapters::ActorLimit.new(sync_adapter, 5)
500
+ test_flipper = Flipper.new(limited_adapter)
501
+
502
+ app = lambda do |env|
503
+ f = env['flipper']
504
+ f[:stats].enabled?
505
+ [200, {}, []]
506
+ end
507
+ middleware = described_class.new(app, preload: true)
508
+
509
+ # Preload should work without raising ActorLimit::LimitExceeded
510
+ expect {
511
+ middleware.call('flipper' => test_flipper)
512
+ }.not_to raise_error
513
+
514
+ # Verify actors were synced (all 10, not just 5)
515
+ expect(test_flipper[:stats].actors_value.size).to eq(10)
485
516
  end
486
517
  end
487
518
  end