togglecraft 1.0.0 → 1.0.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.
- checksums.yaml +4 -4
- data/lib/togglecraft/cache.rb +3 -3
- data/lib/togglecraft/client.rb +4 -3
- data/lib/togglecraft/evaluator.rb +21 -14
- data/lib/togglecraft/shared_sse_connection.rb +0 -1
- data/lib/togglecraft/sse_connection.rb +30 -22
- data/lib/togglecraft/version.rb +1 -1
- data/spec/togglecraft/cache/memory_adapter_spec.rb +2 -2
- data/spec/togglecraft/cache_spec.rb +2 -2
- data/spec/togglecraft/client_spec.rb +4 -4
- data/spec/togglecraft/evaluator_spec.rb +12 -12
- data/spec/togglecraft/sse_connection_spec.rb +1 -1
- data/spec/togglecraft/utils_spec.rb +69 -69
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b9dd914fcd5b95e495b7f63183f53fa849a7f1bd7291548c39cf46d98a3be1d
|
|
4
|
+
data.tar.gz: 9f6f56bea1e188e1c2688c16b726140ae9700811db4d3406332defe8501b6f51
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a55edef61bcbf57d1bfb8db39ec69ee26ad20406d472b429c6a231fb8437b100b43ccbe49fdd2addd69439e4ad06d187cf5e7562e00ea1c21c1ab50235374c12
|
|
7
|
+
data.tar.gz: da54c02f224ea950aa4f0e06564378bf25d9176987b0b25509a73320a23cf26de28cc491cb36219d4def4c5f076174715e433944776e355aa8a5390c0723bbac
|
data/lib/togglecraft/cache.rb
CHANGED
|
@@ -13,11 +13,11 @@ module ToggleCraft
|
|
|
13
13
|
def initialize(adapter: :memory, ttl: 300)
|
|
14
14
|
@default_ttl = ttl
|
|
15
15
|
@adapter = case adapter
|
|
16
|
-
|
|
16
|
+
when :memory
|
|
17
17
|
CacheAdapters::MemoryAdapter.new
|
|
18
|
-
|
|
18
|
+
else
|
|
19
19
|
adapter
|
|
20
|
-
|
|
20
|
+
end
|
|
21
21
|
@cleanup_thread = nil
|
|
22
22
|
start_cleanup
|
|
23
23
|
end
|
data/lib/togglecraft/client.rb
CHANGED
|
@@ -26,7 +26,8 @@ module ToggleCraft
|
|
|
26
26
|
enable_rollout_stage_polling: true,
|
|
27
27
|
rollout_stage_check_interval: 60,
|
|
28
28
|
fetch_jitter: 1500, # milliseconds
|
|
29
|
-
api_domain: 'https://togglecraft.io'
|
|
29
|
+
api_domain: 'https://togglecraft.io',
|
|
30
|
+
heartbeat_domain: 'https://togglecraft.io' # Default heartbeat domain
|
|
30
31
|
}.merge(options).merge(sdk_key: sdk_key)
|
|
31
32
|
|
|
32
33
|
# Initialize components
|
|
@@ -334,7 +335,6 @@ module ToggleCraft
|
|
|
334
335
|
max_reconnect_interval: @config[:max_reconnect_interval],
|
|
335
336
|
max_reconnect_attempts: @config[:max_reconnect_attempts],
|
|
336
337
|
slow_reconnect_interval: @config[:slow_reconnect_interval],
|
|
337
|
-
heartbeat_domain: @config[:heartbeat_domain],
|
|
338
338
|
debug: @config[:debug],
|
|
339
339
|
on_message: method(:handle_flags_update),
|
|
340
340
|
on_connect: method(:handle_connect),
|
|
@@ -393,7 +393,8 @@ module ToggleCraft
|
|
|
393
393
|
@fetch_in_progress.make_true
|
|
394
394
|
|
|
395
395
|
begin
|
|
396
|
-
|
|
396
|
+
# API domain always hardcoded to production
|
|
397
|
+
versioned_url = "https://togglecraft.io/api/v1/flags/#{@project_key.get}/#{@environment_key.get}/v/#{version}"
|
|
397
398
|
log "Fetching flags from #{versioned_url}"
|
|
398
399
|
|
|
399
400
|
response = HTTP.timeout(10).get(versioned_url)
|
|
@@ -18,12 +18,13 @@ module ToggleCraft
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# Evaluate a boolean flag
|
|
21
|
-
# @param flag_key [String] The flag key
|
|
21
|
+
# @param flag_key [String, Symbol] The flag key
|
|
22
22
|
# @param context [Hash] The evaluation context
|
|
23
23
|
# @param default_value [Boolean] Default value if flag not found
|
|
24
24
|
# @return [Boolean]
|
|
25
25
|
def evaluate_boolean(flag_key, context = {}, default_value = false)
|
|
26
|
-
|
|
26
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
27
|
+
flag = @flags[flag_key.to_sym]
|
|
27
28
|
|
|
28
29
|
# Flag doesn't exist
|
|
29
30
|
return default_value unless flag
|
|
@@ -56,7 +57,8 @@ module ToggleCraft
|
|
|
56
57
|
# @param default_variant [String, nil] Default variant if flag not found
|
|
57
58
|
# @return [String, nil]
|
|
58
59
|
def evaluate_multivariate(flag_key, context = {}, default_variant = nil)
|
|
59
|
-
|
|
60
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
61
|
+
flag = @flags[flag_key.to_sym]
|
|
60
62
|
|
|
61
63
|
# Flag doesn't exist
|
|
62
64
|
return default_variant unless flag
|
|
@@ -88,12 +90,13 @@ module ToggleCraft
|
|
|
88
90
|
end
|
|
89
91
|
|
|
90
92
|
# Evaluate a percentage rollout flag
|
|
91
|
-
# @param flag_key [String] The flag key
|
|
93
|
+
# @param flag_key [String, Symbol] The flag key
|
|
92
94
|
# @param context [Hash] The evaluation context
|
|
93
95
|
# @param default_value [Boolean] Default value if flag not found
|
|
94
96
|
# @return [Boolean]
|
|
95
97
|
def evaluate_percentage(flag_key, context = {}, default_value = false)
|
|
96
|
-
|
|
98
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
99
|
+
flag = @flags[flag_key.to_sym]
|
|
97
100
|
|
|
98
101
|
# Flag doesn't exist
|
|
99
102
|
return default_value unless flag
|
|
@@ -158,10 +161,11 @@ module ToggleCraft
|
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
# Get the current percentage value of a flag
|
|
161
|
-
# @param flag_key [String] The flag key
|
|
164
|
+
# @param flag_key [String, Symbol] The flag key
|
|
162
165
|
# @return [Integer, nil]
|
|
163
166
|
def percentage(flag_key)
|
|
164
|
-
|
|
167
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
168
|
+
flag = @flags[flag_key.to_sym]
|
|
165
169
|
|
|
166
170
|
return nil unless flag && flag[:type] == 'percentage'
|
|
167
171
|
|
|
@@ -183,7 +187,7 @@ module ToggleCraft
|
|
|
183
187
|
|
|
184
188
|
result = if result.nil?
|
|
185
189
|
condition_result
|
|
186
|
-
|
|
190
|
+
else
|
|
187
191
|
# Apply combinator logic
|
|
188
192
|
case previous_combinator
|
|
189
193
|
when 'OR'
|
|
@@ -192,7 +196,7 @@ module ToggleCraft
|
|
|
192
196
|
# Default to AND
|
|
193
197
|
result && condition_result
|
|
194
198
|
end
|
|
195
|
-
|
|
199
|
+
end
|
|
196
200
|
|
|
197
201
|
previous_combinator = condition[:combinator] || 'AND'
|
|
198
202
|
|
|
@@ -252,7 +256,8 @@ module ToggleCraft
|
|
|
252
256
|
# @param flag_key [String] The flag key
|
|
253
257
|
# @return [Boolean]
|
|
254
258
|
def has_flag?(flag_key)
|
|
255
|
-
|
|
259
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
260
|
+
@flags.key?(flag_key.to_sym)
|
|
256
261
|
end
|
|
257
262
|
|
|
258
263
|
# Get all flag keys
|
|
@@ -262,10 +267,11 @@ module ToggleCraft
|
|
|
262
267
|
end
|
|
263
268
|
|
|
264
269
|
# Get flag metadata
|
|
265
|
-
# @param flag_key [String] The flag key
|
|
270
|
+
# @param flag_key [String, Symbol] The flag key
|
|
266
271
|
# @return [Hash, nil]
|
|
267
272
|
def flag_metadata(flag_key)
|
|
268
|
-
|
|
273
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
274
|
+
flag = @flags[flag_key.to_sym]
|
|
269
275
|
return nil unless flag
|
|
270
276
|
|
|
271
277
|
metadata = {
|
|
@@ -285,12 +291,13 @@ module ToggleCraft
|
|
|
285
291
|
end
|
|
286
292
|
|
|
287
293
|
# Evaluate any flag type
|
|
288
|
-
# @param flag_key [String] The flag key
|
|
294
|
+
# @param flag_key [String, Symbol] The flag key
|
|
289
295
|
# @param context [Hash] The evaluation context
|
|
290
296
|
# @param default_value [Object] Default value
|
|
291
297
|
# @return [Object]
|
|
292
298
|
def evaluate(flag_key, context = {}, default_value = false)
|
|
293
|
-
|
|
299
|
+
# Normalize key to symbol for lookup (flags are stored with symbol keys)
|
|
300
|
+
flag = @flags[flag_key.to_sym]
|
|
294
301
|
return default_value unless flag
|
|
295
302
|
|
|
296
303
|
case flag[:type]
|
|
@@ -158,7 +158,6 @@ module ToggleCraft
|
|
|
158
158
|
max_reconnect_interval: @config[:max_reconnect_interval],
|
|
159
159
|
max_reconnect_attempts: @config[:max_reconnect_attempts],
|
|
160
160
|
slow_reconnect_interval: @config[:slow_reconnect_interval],
|
|
161
|
-
heartbeat_domain: @config[:heartbeat_domain],
|
|
162
161
|
debug: @debug,
|
|
163
162
|
on_message: method(:handle_message),
|
|
164
163
|
on_connect: method(:handle_connect),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
3
4
|
require 'http'
|
|
4
5
|
require 'json'
|
|
5
6
|
|
|
@@ -32,10 +33,10 @@ module ToggleCraft
|
|
|
32
33
|
@should_reconnect = true
|
|
33
34
|
|
|
34
35
|
# Callbacks
|
|
35
|
-
@on_message = options[:on_message] || proc {}
|
|
36
|
-
@on_connect = options[:on_connect] || proc {}
|
|
37
|
-
@on_disconnect = options[:on_disconnect] || proc {}
|
|
38
|
-
@on_error = options[:on_error] || proc {}
|
|
36
|
+
@on_message = options[:on_message] || proc { }
|
|
37
|
+
@on_connect = options[:on_connect] || proc { }
|
|
38
|
+
@on_disconnect = options[:on_disconnect] || proc { }
|
|
39
|
+
@on_error = options[:on_error] || proc { }
|
|
39
40
|
|
|
40
41
|
# Threads
|
|
41
42
|
@connection_thread = nil
|
|
@@ -111,24 +112,30 @@ module ToggleCraft
|
|
|
111
112
|
sse_url = "#{@url}/events?identifier=#{CGI.escape(channel_identifier)}"
|
|
112
113
|
log "Connecting to #{sse_url}"
|
|
113
114
|
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
# Don't use block form - open connection and keep reference
|
|
116
|
+
response = HTTP.timeout(connect: 10, read: 300)
|
|
117
|
+
.headers('Accept' => 'text/event-stream')
|
|
118
|
+
.get(sse_url)
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
@reconnect_attempts = 0
|
|
119
|
-
@on_connect.call
|
|
120
|
-
start_heartbeat
|
|
120
|
+
raise "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
@connected = true
|
|
123
|
+
@reconnect_attempts = 0
|
|
124
|
+
@on_connect.call
|
|
125
|
+
start_heartbeat
|
|
126
|
+
|
|
127
|
+
# Read SSE stream using readpartial (body.each doesn't work properly)
|
|
128
|
+
buffer = ''
|
|
129
|
+
loop do
|
|
130
|
+
chunk = response.body.readpartial
|
|
131
|
+
break unless chunk # Connection closed
|
|
132
|
+
|
|
133
|
+
buffer += chunk
|
|
126
134
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
end
|
|
135
|
+
# Process complete SSE messages (ending with \n\n)
|
|
136
|
+
while buffer.include?("\n\n")
|
|
137
|
+
message, buffer = buffer.split("\n\n", 2)
|
|
138
|
+
process_sse_message(message)
|
|
132
139
|
end
|
|
133
140
|
end
|
|
134
141
|
rescue StandardError => e
|
|
@@ -197,8 +204,8 @@ module ToggleCraft
|
|
|
197
204
|
log "Max fast reconnection attempts reached, switching to slow mode (every #{interval}s)"
|
|
198
205
|
else
|
|
199
206
|
@reconnect_attempts += 1
|
|
200
|
-
interval = [@options[:reconnect_interval] * (2**(@reconnect_attempts - 1)),
|
|
201
|
-
@options[:max_reconnect_interval]].min
|
|
207
|
+
interval = [ @options[:reconnect_interval] * (2**(@reconnect_attempts - 1)),
|
|
208
|
+
@options[:max_reconnect_interval] ].min
|
|
202
209
|
log "Scheduling reconnection attempt #{@reconnect_attempts}/" \
|
|
203
210
|
"#{@options[:max_reconnect_attempts]} in #{interval}s"
|
|
204
211
|
end
|
|
@@ -256,7 +263,8 @@ module ToggleCraft
|
|
|
256
263
|
end
|
|
257
264
|
|
|
258
265
|
begin
|
|
259
|
-
|
|
266
|
+
# Heartbeat always goes to production API (hardcoded)
|
|
267
|
+
heartbeat_url = "https://togglecraft.io/api/v1/heartbeat"
|
|
260
268
|
log "Sending heartbeat to #{heartbeat_url}"
|
|
261
269
|
|
|
262
270
|
response = HTTP.timeout(10)
|
data/lib/togglecraft/version.rb
CHANGED
|
@@ -30,12 +30,12 @@ RSpec.describe ToggleCraft::CacheAdapters::MemoryAdapter do
|
|
|
30
30
|
adapter.set('string', 'text')
|
|
31
31
|
adapter.set('number', 123)
|
|
32
32
|
adapter.set('hash', { foo: 'bar' })
|
|
33
|
-
adapter.set('array', [1, 2, 3])
|
|
33
|
+
adapter.set('array', [ 1, 2, 3 ])
|
|
34
34
|
|
|
35
35
|
expect(adapter.get('string')).to eq('text')
|
|
36
36
|
expect(adapter.get('number')).to eq(123)
|
|
37
37
|
expect(adapter.get('hash')).to eq({ foo: 'bar' })
|
|
38
|
-
expect(adapter.get('array')).to eq([1, 2, 3])
|
|
38
|
+
expect(adapter.get('array')).to eq([ 1, 2, 3 ])
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -48,13 +48,13 @@ RSpec.describe ToggleCraft::Cache do
|
|
|
48
48
|
cache.set('string', 'text')
|
|
49
49
|
cache.set('number', 123)
|
|
50
50
|
cache.set('hash', { foo: 'bar' })
|
|
51
|
-
cache.set('array', [1, 2, 3])
|
|
51
|
+
cache.set('array', [ 1, 2, 3 ])
|
|
52
52
|
cache.set('boolean', true)
|
|
53
53
|
|
|
54
54
|
expect(cache.get('string')).to eq('text')
|
|
55
55
|
expect(cache.get('number')).to eq(123)
|
|
56
56
|
expect(cache.get('hash')).to eq({ foo: 'bar' })
|
|
57
|
-
expect(cache.get('array')).to eq([1, 2, 3])
|
|
57
|
+
expect(cache.get('array')).to eq([ 1, 2, 3 ])
|
|
58
58
|
expect(cache.get('boolean')).to be true
|
|
59
59
|
end
|
|
60
60
|
end
|
|
@@ -203,7 +203,7 @@ RSpec.describe ToggleCraft::Client do
|
|
|
203
203
|
it 'accepts context for evaluation' do
|
|
204
204
|
context = { user: { id: '123', role: 'admin' } }
|
|
205
205
|
result = client.enabled?('boolean-flag', context)
|
|
206
|
-
expect([true, false]).to include(result)
|
|
206
|
+
expect([ true, false ]).to include(result)
|
|
207
207
|
end
|
|
208
208
|
|
|
209
209
|
it 'caches evaluation results when cache is enabled' do
|
|
@@ -261,7 +261,7 @@ RSpec.describe ToggleCraft::Client do
|
|
|
261
261
|
it 'evaluates percentage flags' do
|
|
262
262
|
context = { user: { id: '123' } }
|
|
263
263
|
result = client.in_percentage?('percentage-flag', context)
|
|
264
|
-
expect([true, false]).to include(result)
|
|
264
|
+
expect([ true, false ]).to include(result)
|
|
265
265
|
end
|
|
266
266
|
|
|
267
267
|
it 'returns default value for missing flags' do
|
|
@@ -297,7 +297,7 @@ RSpec.describe ToggleCraft::Client do
|
|
|
297
297
|
expect(client.evaluate('boolean-flag')).to be true
|
|
298
298
|
expect(%w[control variant-a]).to include(client.evaluate('multivariate-flag', { user: { id: '123' } }))
|
|
299
299
|
result = client.evaluate('percentage-flag', { user: { id: '123' } })
|
|
300
|
-
expect([true, false]).to include(result)
|
|
300
|
+
expect([ true, false ]).to include(result)
|
|
301
301
|
end
|
|
302
302
|
|
|
303
303
|
it 'returns default for missing flags' do
|
|
@@ -396,7 +396,7 @@ RSpec.describe ToggleCraft::Client do
|
|
|
396
396
|
client.send(:emit, :reconnecting)
|
|
397
397
|
client.send(:emit, :rollout_stage_changed, {})
|
|
398
398
|
|
|
399
|
-
expect(events_received).to eq([:ready, :flags_updated, :error, :disconnected, :reconnecting, :rollout_stage_changed])
|
|
399
|
+
expect(events_received).to eq([ :ready, :flags_updated, :error, :disconnected, :reconnecting, :rollout_stage_changed ])
|
|
400
400
|
end
|
|
401
401
|
|
|
402
402
|
it 'handles errors in event listeners gracefully' do
|
|
@@ -41,7 +41,7 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
41
41
|
rules: [
|
|
42
42
|
{
|
|
43
43
|
conditions: [
|
|
44
|
-
{ attribute: 'user.role', operator: 'equals', values: ['admin'] }
|
|
44
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'admin' ] }
|
|
45
45
|
],
|
|
46
46
|
value: true
|
|
47
47
|
}
|
|
@@ -110,7 +110,7 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
110
110
|
rules: [
|
|
111
111
|
{
|
|
112
112
|
conditions: [
|
|
113
|
-
{ attribute: 'user.plan', operator: 'equals', values: ['premium'] }
|
|
113
|
+
{ attribute: 'user.plan', operator: 'equals', values: [ 'premium' ] }
|
|
114
114
|
],
|
|
115
115
|
variant: 'premium-variant'
|
|
116
116
|
}
|
|
@@ -179,7 +179,7 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
179
179
|
rules: [
|
|
180
180
|
{
|
|
181
181
|
conditions: [
|
|
182
|
-
{ attribute: 'user.beta_tester', operator: 'equals', values: ['true'] }
|
|
182
|
+
{ attribute: 'user.beta_tester', operator: 'equals', values: [ 'true' ] }
|
|
183
183
|
],
|
|
184
184
|
enabled: true
|
|
185
185
|
}
|
|
@@ -301,8 +301,8 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
301
301
|
it 'returns true when all AND conditions pass' do
|
|
302
302
|
rule = {
|
|
303
303
|
conditions: [
|
|
304
|
-
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'AND' },
|
|
305
|
-
{ attribute: 'user.active', operator: 'equals', values: ['true'], combinator: 'AND' }
|
|
304
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'admin' ], combinator: 'AND' },
|
|
305
|
+
{ attribute: 'user.active', operator: 'equals', values: [ 'true' ], combinator: 'AND' }
|
|
306
306
|
]
|
|
307
307
|
}
|
|
308
308
|
context = { user: { role: 'admin', active: 'true' } }
|
|
@@ -313,8 +313,8 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
313
313
|
it 'returns false when any AND condition fails' do
|
|
314
314
|
rule = {
|
|
315
315
|
conditions: [
|
|
316
|
-
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'AND' },
|
|
317
|
-
{ attribute: 'user.active', operator: 'equals', values: ['true'], combinator: 'AND' }
|
|
316
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'admin' ], combinator: 'AND' },
|
|
317
|
+
{ attribute: 'user.active', operator: 'equals', values: [ 'true' ], combinator: 'AND' }
|
|
318
318
|
]
|
|
319
319
|
}
|
|
320
320
|
context = { user: { role: 'admin', active: 'false' } }
|
|
@@ -325,8 +325,8 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
325
325
|
it 'returns true when any OR condition passes' do
|
|
326
326
|
rule = {
|
|
327
327
|
conditions: [
|
|
328
|
-
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'OR' },
|
|
329
|
-
{ attribute: 'user.role', operator: 'equals', values: ['superuser'], combinator: 'OR' }
|
|
328
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'admin' ], combinator: 'OR' },
|
|
329
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'superuser' ], combinator: 'OR' }
|
|
330
330
|
]
|
|
331
331
|
}
|
|
332
332
|
context = { user: { role: 'superuser' } }
|
|
@@ -337,9 +337,9 @@ RSpec.describe ToggleCraft::Evaluator do
|
|
|
337
337
|
it 'handles mixed AND/OR combinators' do
|
|
338
338
|
rule = {
|
|
339
339
|
conditions: [
|
|
340
|
-
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'OR' },
|
|
341
|
-
{ attribute: 'user.department', operator: 'equals', values: ['engineering'], combinator: 'AND' },
|
|
342
|
-
{ attribute: 'user.level', operator: 'gt', values: ['5'] }
|
|
340
|
+
{ attribute: 'user.role', operator: 'equals', values: [ 'admin' ], combinator: 'OR' },
|
|
341
|
+
{ attribute: 'user.department', operator: 'equals', values: [ 'engineering' ], combinator: 'AND' },
|
|
342
|
+
{ attribute: 'user.level', operator: 'gt', values: [ '5' ] }
|
|
343
343
|
]
|
|
344
344
|
}
|
|
345
345
|
|
|
@@ -230,75 +230,75 @@ RSpec.describe ToggleCraft::Utils do
|
|
|
230
230
|
describe '.evaluate_operator' do
|
|
231
231
|
describe 'equals operator' do
|
|
232
232
|
it 'returns true when values are equal (case-insensitive)' do
|
|
233
|
-
expect(described_class.evaluate_operator('test', 'equals', ['test'])).to be true
|
|
234
|
-
expect(described_class.evaluate_operator('Test', 'equals', ['test'])).to be true
|
|
235
|
-
expect(described_class.evaluate_operator('TEST', 'equals', ['test'])).to be true
|
|
233
|
+
expect(described_class.evaluate_operator('test', 'equals', [ 'test' ])).to be true
|
|
234
|
+
expect(described_class.evaluate_operator('Test', 'equals', [ 'test' ])).to be true
|
|
235
|
+
expect(described_class.evaluate_operator('TEST', 'equals', [ 'test' ])).to be true
|
|
236
236
|
end
|
|
237
237
|
|
|
238
238
|
it 'returns false when values are not equal' do
|
|
239
|
-
expect(described_class.evaluate_operator('test', 'equals', ['other'])).to be false
|
|
239
|
+
expect(described_class.evaluate_operator('test', 'equals', [ 'other' ])).to be false
|
|
240
240
|
end
|
|
241
241
|
|
|
242
242
|
it 'handles numeric values' do
|
|
243
|
-
expect(described_class.evaluate_operator(123, 'equals', [123])).to be true
|
|
244
|
-
expect(described_class.evaluate_operator(123, 'equals', ['123'])).to be true
|
|
243
|
+
expect(described_class.evaluate_operator(123, 'equals', [ 123 ])).to be true
|
|
244
|
+
expect(described_class.evaluate_operator(123, 'equals', [ '123' ])).to be true
|
|
245
245
|
end
|
|
246
246
|
end
|
|
247
247
|
|
|
248
248
|
describe 'not_equals operator' do
|
|
249
249
|
it 'returns false when values are equal' do
|
|
250
|
-
expect(described_class.evaluate_operator('test', 'not_equals', ['test'])).to be false
|
|
250
|
+
expect(described_class.evaluate_operator('test', 'not_equals', [ 'test' ])).to be false
|
|
251
251
|
end
|
|
252
252
|
|
|
253
253
|
it 'returns true when values are not equal' do
|
|
254
|
-
expect(described_class.evaluate_operator('test', 'not_equals', ['other'])).to be true
|
|
254
|
+
expect(described_class.evaluate_operator('test', 'not_equals', [ 'other' ])).to be true
|
|
255
255
|
end
|
|
256
256
|
|
|
257
257
|
it 'returns true for nil values' do
|
|
258
|
-
expect(described_class.evaluate_operator(nil, 'not_equals', ['test'])).to be true
|
|
258
|
+
expect(described_class.evaluate_operator(nil, 'not_equals', [ 'test' ])).to be true
|
|
259
259
|
end
|
|
260
260
|
end
|
|
261
261
|
|
|
262
262
|
describe 'contains operator' do
|
|
263
263
|
it 'returns true when attribute contains value' do
|
|
264
|
-
expect(described_class.evaluate_operator('hello world', 'contains', ['world'])).to be true
|
|
265
|
-
expect(described_class.evaluate_operator('HELLO WORLD', 'contains', ['world'])).to be true
|
|
264
|
+
expect(described_class.evaluate_operator('hello world', 'contains', [ 'world' ])).to be true
|
|
265
|
+
expect(described_class.evaluate_operator('HELLO WORLD', 'contains', [ 'world' ])).to be true
|
|
266
266
|
end
|
|
267
267
|
|
|
268
268
|
it 'returns false when attribute does not contain value' do
|
|
269
|
-
expect(described_class.evaluate_operator('hello world', 'contains', ['xyz'])).to be false
|
|
269
|
+
expect(described_class.evaluate_operator('hello world', 'contains', [ 'xyz' ])).to be false
|
|
270
270
|
end
|
|
271
271
|
end
|
|
272
272
|
|
|
273
273
|
describe 'not_contains operator' do
|
|
274
274
|
it 'returns false when attribute contains value' do
|
|
275
|
-
expect(described_class.evaluate_operator('hello world', 'not_contains', ['world'])).to be false
|
|
275
|
+
expect(described_class.evaluate_operator('hello world', 'not_contains', [ 'world' ])).to be false
|
|
276
276
|
end
|
|
277
277
|
|
|
278
278
|
it 'returns true when attribute does not contain value' do
|
|
279
|
-
expect(described_class.evaluate_operator('hello world', 'not_contains', ['xyz'])).to be true
|
|
279
|
+
expect(described_class.evaluate_operator('hello world', 'not_contains', [ 'xyz' ])).to be true
|
|
280
280
|
end
|
|
281
281
|
end
|
|
282
282
|
|
|
283
283
|
describe 'starts_with operator' do
|
|
284
284
|
it 'returns true when attribute starts with value' do
|
|
285
|
-
expect(described_class.evaluate_operator('hello world', 'starts_with', ['hello'])).to be true
|
|
286
|
-
expect(described_class.evaluate_operator('HELLO WORLD', 'starts_with', ['hello'])).to be true
|
|
285
|
+
expect(described_class.evaluate_operator('hello world', 'starts_with', [ 'hello' ])).to be true
|
|
286
|
+
expect(described_class.evaluate_operator('HELLO WORLD', 'starts_with', [ 'hello' ])).to be true
|
|
287
287
|
end
|
|
288
288
|
|
|
289
289
|
it 'returns false when attribute does not start with value' do
|
|
290
|
-
expect(described_class.evaluate_operator('hello world', 'starts_with', ['world'])).to be false
|
|
290
|
+
expect(described_class.evaluate_operator('hello world', 'starts_with', [ 'world' ])).to be false
|
|
291
291
|
end
|
|
292
292
|
end
|
|
293
293
|
|
|
294
294
|
describe 'ends_with operator' do
|
|
295
295
|
it 'returns true when attribute ends with value' do
|
|
296
|
-
expect(described_class.evaluate_operator('hello world', 'ends_with', ['world'])).to be true
|
|
297
|
-
expect(described_class.evaluate_operator('HELLO WORLD', 'ends_with', ['world'])).to be true
|
|
296
|
+
expect(described_class.evaluate_operator('hello world', 'ends_with', [ 'world' ])).to be true
|
|
297
|
+
expect(described_class.evaluate_operator('HELLO WORLD', 'ends_with', [ 'world' ])).to be true
|
|
298
298
|
end
|
|
299
299
|
|
|
300
300
|
it 'returns false when attribute does not end with value' do
|
|
301
|
-
expect(described_class.evaluate_operator('hello world', 'ends_with', ['hello'])).to be false
|
|
301
|
+
expect(described_class.evaluate_operator('hello world', 'ends_with', [ 'hello' ])).to be false
|
|
302
302
|
end
|
|
303
303
|
end
|
|
304
304
|
|
|
@@ -313,8 +313,8 @@ RSpec.describe ToggleCraft::Utils do
|
|
|
313
313
|
end
|
|
314
314
|
|
|
315
315
|
it 'handles numeric values' do
|
|
316
|
-
expect(described_class.evaluate_operator(1, 'in', [1, 2, 3])).to be true
|
|
317
|
-
expect(described_class.evaluate_operator(4, 'in', [1, 2, 3])).to be false
|
|
316
|
+
expect(described_class.evaluate_operator(1, 'in', [ 1, 2, 3 ])).to be true
|
|
317
|
+
expect(described_class.evaluate_operator(4, 'in', [ 1, 2, 3 ])).to be false
|
|
318
318
|
end
|
|
319
319
|
end
|
|
320
320
|
|
|
@@ -334,170 +334,170 @@ RSpec.describe ToggleCraft::Utils do
|
|
|
334
334
|
|
|
335
335
|
describe 'gt operator' do
|
|
336
336
|
it 'returns true when attribute is greater than value' do
|
|
337
|
-
expect(described_class.evaluate_operator(10, 'gt', [5])).to be true
|
|
338
|
-
expect(described_class.evaluate_operator(5.5, 'gt', [5])).to be true
|
|
337
|
+
expect(described_class.evaluate_operator(10, 'gt', [ 5 ])).to be true
|
|
338
|
+
expect(described_class.evaluate_operator(5.5, 'gt', [ 5 ])).to be true
|
|
339
339
|
end
|
|
340
340
|
|
|
341
341
|
it 'returns false when attribute is less than or equal to value' do
|
|
342
|
-
expect(described_class.evaluate_operator(5, 'gt', [10])).to be false
|
|
343
|
-
expect(described_class.evaluate_operator(5, 'gt', [5])).to be false
|
|
342
|
+
expect(described_class.evaluate_operator(5, 'gt', [ 10 ])).to be false
|
|
343
|
+
expect(described_class.evaluate_operator(5, 'gt', [ 5 ])).to be false
|
|
344
344
|
end
|
|
345
345
|
|
|
346
346
|
it 'handles string numeric values' do
|
|
347
|
-
expect(described_class.evaluate_operator('10', 'gt', ['5'])).to be true
|
|
347
|
+
expect(described_class.evaluate_operator('10', 'gt', [ '5' ])).to be true
|
|
348
348
|
end
|
|
349
349
|
end
|
|
350
350
|
|
|
351
351
|
describe 'gte operator' do
|
|
352
352
|
it 'returns true when attribute is greater than or equal to value' do
|
|
353
|
-
expect(described_class.evaluate_operator(10, 'gte', [5])).to be true
|
|
354
|
-
expect(described_class.evaluate_operator(5, 'gte', [5])).to be true
|
|
353
|
+
expect(described_class.evaluate_operator(10, 'gte', [ 5 ])).to be true
|
|
354
|
+
expect(described_class.evaluate_operator(5, 'gte', [ 5 ])).to be true
|
|
355
355
|
end
|
|
356
356
|
|
|
357
357
|
it 'returns false when attribute is less than value' do
|
|
358
|
-
expect(described_class.evaluate_operator(5, 'gte', [10])).to be false
|
|
358
|
+
expect(described_class.evaluate_operator(5, 'gte', [ 10 ])).to be false
|
|
359
359
|
end
|
|
360
360
|
end
|
|
361
361
|
|
|
362
362
|
describe 'lt operator' do
|
|
363
363
|
it 'returns true when attribute is less than value' do
|
|
364
|
-
expect(described_class.evaluate_operator(5, 'lt', [10])).to be true
|
|
364
|
+
expect(described_class.evaluate_operator(5, 'lt', [ 10 ])).to be true
|
|
365
365
|
end
|
|
366
366
|
|
|
367
367
|
it 'returns false when attribute is greater than or equal to value' do
|
|
368
|
-
expect(described_class.evaluate_operator(10, 'lt', [5])).to be false
|
|
369
|
-
expect(described_class.evaluate_operator(5, 'lt', [5])).to be false
|
|
368
|
+
expect(described_class.evaluate_operator(10, 'lt', [ 5 ])).to be false
|
|
369
|
+
expect(described_class.evaluate_operator(5, 'lt', [ 5 ])).to be false
|
|
370
370
|
end
|
|
371
371
|
end
|
|
372
372
|
|
|
373
373
|
describe 'lte operator' do
|
|
374
374
|
it 'returns true when attribute is less than or equal to value' do
|
|
375
|
-
expect(described_class.evaluate_operator(5, 'lte', [10])).to be true
|
|
376
|
-
expect(described_class.evaluate_operator(5, 'lte', [5])).to be true
|
|
375
|
+
expect(described_class.evaluate_operator(5, 'lte', [ 10 ])).to be true
|
|
376
|
+
expect(described_class.evaluate_operator(5, 'lte', [ 5 ])).to be true
|
|
377
377
|
end
|
|
378
378
|
|
|
379
379
|
it 'returns false when attribute is greater than value' do
|
|
380
|
-
expect(described_class.evaluate_operator(10, 'lte', [5])).to be false
|
|
380
|
+
expect(described_class.evaluate_operator(10, 'lte', [ 5 ])).to be false
|
|
381
381
|
end
|
|
382
382
|
end
|
|
383
383
|
|
|
384
384
|
describe 'between operator' do
|
|
385
385
|
it 'returns true when attribute is between min and max (inclusive)' do
|
|
386
|
-
expect(described_class.evaluate_operator(5, 'between', [1, 10])).to be true
|
|
387
|
-
expect(described_class.evaluate_operator(1, 'between', [1, 10])).to be true
|
|
388
|
-
expect(described_class.evaluate_operator(10, 'between', [1, 10])).to be true
|
|
386
|
+
expect(described_class.evaluate_operator(5, 'between', [ 1, 10 ])).to be true
|
|
387
|
+
expect(described_class.evaluate_operator(1, 'between', [ 1, 10 ])).to be true
|
|
388
|
+
expect(described_class.evaluate_operator(10, 'between', [ 1, 10 ])).to be true
|
|
389
389
|
end
|
|
390
390
|
|
|
391
391
|
it 'returns false when attribute is outside range' do
|
|
392
|
-
expect(described_class.evaluate_operator(0, 'between', [1, 10])).to be false
|
|
393
|
-
expect(described_class.evaluate_operator(11, 'between', [1, 10])).to be false
|
|
392
|
+
expect(described_class.evaluate_operator(0, 'between', [ 1, 10 ])).to be false
|
|
393
|
+
expect(described_class.evaluate_operator(11, 'between', [ 1, 10 ])).to be false
|
|
394
394
|
end
|
|
395
395
|
|
|
396
396
|
it 'returns false when condition values are insufficient' do
|
|
397
|
-
expect(described_class.evaluate_operator(5, 'between', [1])).to be false
|
|
397
|
+
expect(described_class.evaluate_operator(5, 'between', [ 1 ])).to be false
|
|
398
398
|
end
|
|
399
399
|
|
|
400
400
|
it 'handles decimal values' do
|
|
401
|
-
expect(described_class.evaluate_operator(5.5, 'between', [5, 6])).to be true
|
|
401
|
+
expect(described_class.evaluate_operator(5.5, 'between', [ 5, 6 ])).to be true
|
|
402
402
|
end
|
|
403
403
|
end
|
|
404
404
|
|
|
405
405
|
describe 'regex operator' do
|
|
406
406
|
it 'returns true when attribute matches regex' do
|
|
407
|
-
expect(described_class.evaluate_operator('test123', 'regex', ['^test\\d+$'])).to be true
|
|
408
|
-
expect(described_class.evaluate_operator('user@example.com', 'regex', ['^[^@]+@[^@]+$'])).to be true
|
|
407
|
+
expect(described_class.evaluate_operator('test123', 'regex', [ '^test\\d+$' ])).to be true
|
|
408
|
+
expect(described_class.evaluate_operator('user@example.com', 'regex', [ '^[^@]+@[^@]+$' ])).to be true
|
|
409
409
|
end
|
|
410
410
|
|
|
411
411
|
it 'returns false when attribute does not match regex' do
|
|
412
|
-
expect(described_class.evaluate_operator('test', 'regex', ['^\\d+$'])).to be false
|
|
412
|
+
expect(described_class.evaluate_operator('test', 'regex', [ '^\\d+$' ])).to be false
|
|
413
413
|
end
|
|
414
414
|
|
|
415
415
|
it 'returns false for invalid regex' do
|
|
416
|
-
expect(described_class.evaluate_operator('test', 'regex', ['[invalid'])).to be false
|
|
416
|
+
expect(described_class.evaluate_operator('test', 'regex', [ '[invalid' ])).to be false
|
|
417
417
|
end
|
|
418
418
|
|
|
419
419
|
it 'handles complex patterns' do
|
|
420
|
-
expect(described_class.evaluate_operator('192.168.1.1', 'regex', ['^\\d+\\.\\d+\\.\\d+\\.\\d+$'])).to be true
|
|
420
|
+
expect(described_class.evaluate_operator('192.168.1.1', 'regex', [ '^\\d+\\.\\d+\\.\\d+\\.\\d+$' ])).to be true
|
|
421
421
|
end
|
|
422
422
|
end
|
|
423
423
|
|
|
424
424
|
describe 'semver operators' do
|
|
425
425
|
describe 'semver_eq' do
|
|
426
426
|
it 'returns true for equal versions' do
|
|
427
|
-
expect(described_class.evaluate_operator('1.2.3', 'semver_eq', ['1.2.3'])).to be true
|
|
427
|
+
expect(described_class.evaluate_operator('1.2.3', 'semver_eq', [ '1.2.3' ])).to be true
|
|
428
428
|
end
|
|
429
429
|
|
|
430
430
|
it 'returns false for different versions' do
|
|
431
|
-
expect(described_class.evaluate_operator('1.2.3', 'semver_eq', ['1.2.4'])).to be false
|
|
431
|
+
expect(described_class.evaluate_operator('1.2.3', 'semver_eq', [ '1.2.4' ])).to be false
|
|
432
432
|
end
|
|
433
433
|
end
|
|
434
434
|
|
|
435
435
|
describe 'semver_gt' do
|
|
436
436
|
it 'returns true when attribute version is greater' do
|
|
437
|
-
expect(described_class.evaluate_operator('2.0.0', 'semver_gt', ['1.0.0'])).to be true
|
|
438
|
-
expect(described_class.evaluate_operator('1.1.0', 'semver_gt', ['1.0.0'])).to be true
|
|
437
|
+
expect(described_class.evaluate_operator('2.0.0', 'semver_gt', [ '1.0.0' ])).to be true
|
|
438
|
+
expect(described_class.evaluate_operator('1.1.0', 'semver_gt', [ '1.0.0' ])).to be true
|
|
439
439
|
end
|
|
440
440
|
|
|
441
441
|
it 'returns false when attribute version is less or equal' do
|
|
442
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_gt', ['2.0.0'])).to be false
|
|
443
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_gt', ['1.0.0'])).to be false
|
|
442
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_gt', [ '2.0.0' ])).to be false
|
|
443
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_gt', [ '1.0.0' ])).to be false
|
|
444
444
|
end
|
|
445
445
|
end
|
|
446
446
|
|
|
447
447
|
describe 'semver_gte' do
|
|
448
448
|
it 'returns true when attribute version is greater or equal' do
|
|
449
|
-
expect(described_class.evaluate_operator('2.0.0', 'semver_gte', ['1.0.0'])).to be true
|
|
450
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_gte', ['1.0.0'])).to be true
|
|
449
|
+
expect(described_class.evaluate_operator('2.0.0', 'semver_gte', [ '1.0.0' ])).to be true
|
|
450
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_gte', [ '1.0.0' ])).to be true
|
|
451
451
|
end
|
|
452
452
|
|
|
453
453
|
it 'returns false when attribute version is less' do
|
|
454
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_gte', ['2.0.0'])).to be false
|
|
454
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_gte', [ '2.0.0' ])).to be false
|
|
455
455
|
end
|
|
456
456
|
end
|
|
457
457
|
|
|
458
458
|
describe 'semver_lt' do
|
|
459
459
|
it 'returns true when attribute version is less' do
|
|
460
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_lt', ['2.0.0'])).to be true
|
|
460
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_lt', [ '2.0.0' ])).to be true
|
|
461
461
|
end
|
|
462
462
|
|
|
463
463
|
it 'returns false when attribute version is greater or equal' do
|
|
464
|
-
expect(described_class.evaluate_operator('2.0.0', 'semver_lt', ['1.0.0'])).to be false
|
|
465
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_lt', ['1.0.0'])).to be false
|
|
464
|
+
expect(described_class.evaluate_operator('2.0.0', 'semver_lt', [ '1.0.0' ])).to be false
|
|
465
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_lt', [ '1.0.0' ])).to be false
|
|
466
466
|
end
|
|
467
467
|
end
|
|
468
468
|
|
|
469
469
|
describe 'semver_lte' do
|
|
470
470
|
it 'returns true when attribute version is less or equal' do
|
|
471
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_lte', ['2.0.0'])).to be true
|
|
472
|
-
expect(described_class.evaluate_operator('1.0.0', 'semver_lte', ['1.0.0'])).to be true
|
|
471
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_lte', [ '2.0.0' ])).to be true
|
|
472
|
+
expect(described_class.evaluate_operator('1.0.0', 'semver_lte', [ '1.0.0' ])).to be true
|
|
473
473
|
end
|
|
474
474
|
|
|
475
475
|
it 'returns false when attribute version is greater' do
|
|
476
|
-
expect(described_class.evaluate_operator('2.0.0', 'semver_lte', ['1.0.0'])).to be false
|
|
476
|
+
expect(described_class.evaluate_operator('2.0.0', 'semver_lte', [ '1.0.0' ])).to be false
|
|
477
477
|
end
|
|
478
478
|
end
|
|
479
479
|
end
|
|
480
480
|
|
|
481
481
|
describe 'nil attribute handling' do
|
|
482
482
|
it 'returns false for most operators when attribute is nil' do
|
|
483
|
-
expect(described_class.evaluate_operator(nil, 'equals', ['test'])).to be false
|
|
484
|
-
expect(described_class.evaluate_operator(nil, 'contains', ['test'])).to be false
|
|
485
|
-
expect(described_class.evaluate_operator(nil, 'in', ['test'])).to be false
|
|
483
|
+
expect(described_class.evaluate_operator(nil, 'equals', [ 'test' ])).to be false
|
|
484
|
+
expect(described_class.evaluate_operator(nil, 'contains', [ 'test' ])).to be false
|
|
485
|
+
expect(described_class.evaluate_operator(nil, 'in', [ 'test' ])).to be false
|
|
486
486
|
end
|
|
487
487
|
|
|
488
488
|
it 'returns true for not_equals when attribute is nil' do
|
|
489
|
-
expect(described_class.evaluate_operator(nil, 'not_equals', ['test'])).to be true
|
|
489
|
+
expect(described_class.evaluate_operator(nil, 'not_equals', [ 'test' ])).to be true
|
|
490
490
|
end
|
|
491
491
|
|
|
492
492
|
it 'returns true for not_in when attribute is nil' do
|
|
493
|
-
expect(described_class.evaluate_operator(nil, 'not_in', ['test'])).to be true
|
|
493
|
+
expect(described_class.evaluate_operator(nil, 'not_in', [ 'test' ])).to be true
|
|
494
494
|
end
|
|
495
495
|
end
|
|
496
496
|
|
|
497
497
|
describe 'unknown operator' do
|
|
498
498
|
it 'returns false and warns for unknown operator' do
|
|
499
499
|
expect do
|
|
500
|
-
result = described_class.evaluate_operator('test', 'unknown_op', ['value'])
|
|
500
|
+
result = described_class.evaluate_operator('test', 'unknown_op', [ 'value' ])
|
|
501
501
|
expect(result).to be false
|
|
502
502
|
end.to output(/Unknown operator: unknown_op/).to_stderr
|
|
503
503
|
end
|