togglecraft 1.0.0
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 +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::ConnectionPool do
|
|
4
|
+
let(:config) do
|
|
5
|
+
{
|
|
6
|
+
url: 'https://test.togglecraft.io',
|
|
7
|
+
sdk_key: 'test-key-123',
|
|
8
|
+
share_connection: true
|
|
9
|
+
}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
after do
|
|
13
|
+
described_class.clear(force: true)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '.generate_pool_key' do
|
|
17
|
+
it 'generates key from url and sdk_key' do
|
|
18
|
+
key = described_class.generate_pool_key(config)
|
|
19
|
+
expect(key).to eq('https://test.togglecraft.io:test-key-123')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'uses custom connection_pool_key if provided' do
|
|
23
|
+
config_with_custom_key = config.merge(connection_pool_key: 'custom-key')
|
|
24
|
+
key = described_class.generate_pool_key(config_with_custom_key)
|
|
25
|
+
expect(key).to eq('custom-key')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'uses default URL when not provided' do
|
|
29
|
+
config_without_url = { sdk_key: 'test-key' }
|
|
30
|
+
key = described_class.generate_pool_key(config_without_url)
|
|
31
|
+
expect(key).to eq('https://sse.togglecraft.io:test-key')
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '.get_connection' do
|
|
36
|
+
it 'returns nil when share_connection is false' do
|
|
37
|
+
config_no_share = config.merge(share_connection: false)
|
|
38
|
+
connection = described_class.get_connection(config_no_share)
|
|
39
|
+
expect(connection).to be_nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'creates a new shared connection' do
|
|
43
|
+
connection = described_class.get_connection(config)
|
|
44
|
+
expect(connection).to be_a(ToggleCraft::SharedSSEConnection)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'reuses existing connection for same pool key' do
|
|
48
|
+
conn1 = described_class.get_connection(config)
|
|
49
|
+
conn2 = described_class.get_connection(config)
|
|
50
|
+
expect(conn1).to eq(conn2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'creates different connections for different pool keys' do
|
|
54
|
+
config2 = config.merge(sdk_key: 'different-key')
|
|
55
|
+
|
|
56
|
+
conn1 = described_class.get_connection(config)
|
|
57
|
+
conn2 = described_class.get_connection(config2)
|
|
58
|
+
|
|
59
|
+
expect(conn1).not_to eq(conn2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'sets up empty callback to clean up connection' do
|
|
63
|
+
connection = described_class.get_connection(config)
|
|
64
|
+
|
|
65
|
+
# Trigger the empty callback
|
|
66
|
+
connection.instance_variable_get(:@on_empty_callback).call
|
|
67
|
+
|
|
68
|
+
# Connection should be removed from pool
|
|
69
|
+
pool_key = described_class.generate_pool_key(config)
|
|
70
|
+
expect(described_class.has_connection?(pool_key)).to be false
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe '.stats' do
|
|
75
|
+
it 'returns stats with no connections' do
|
|
76
|
+
stats = described_class.stats
|
|
77
|
+
expect(stats[:total_connections]).to eq(0)
|
|
78
|
+
expect(stats[:connections]).to eq([])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns stats for existing connections' do
|
|
82
|
+
described_class.get_connection(config)
|
|
83
|
+
|
|
84
|
+
stats = described_class.stats
|
|
85
|
+
expect(stats[:total_connections]).to eq(1)
|
|
86
|
+
expect(stats[:connections].length).to eq(1)
|
|
87
|
+
expect(stats[:connections].first[:key]).to eq('https://test.togglecraft.io:test-key-123')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '.clear' do
|
|
92
|
+
it 'clears all connections' do
|
|
93
|
+
described_class.get_connection(config)
|
|
94
|
+
described_class.clear
|
|
95
|
+
|
|
96
|
+
expect(described_class.connection_count).to eq(0)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'force disconnects when force flag is true' do
|
|
100
|
+
connection = described_class.get_connection(config)
|
|
101
|
+
expect(connection).to receive(:force_disconnect)
|
|
102
|
+
|
|
103
|
+
described_class.clear(force: true)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'does not force disconnect when force flag is false' do
|
|
107
|
+
connection = described_class.get_connection(config)
|
|
108
|
+
expect(connection).not_to receive(:force_disconnect)
|
|
109
|
+
|
|
110
|
+
described_class.clear(force: false)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '.connection_count' do
|
|
115
|
+
it 'returns 0 with no connections' do
|
|
116
|
+
expect(described_class.connection_count).to eq(0)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'returns count of active connections' do
|
|
120
|
+
described_class.get_connection(config)
|
|
121
|
+
described_class.get_connection(config.merge(sdk_key: 'key2'))
|
|
122
|
+
|
|
123
|
+
expect(described_class.connection_count).to eq(2)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '.total_client_count' do
|
|
128
|
+
it 'returns 0 with no connections' do
|
|
129
|
+
expect(described_class.total_client_count).to eq(0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'returns total clients across all connections' do
|
|
133
|
+
conn1 = described_class.get_connection(config)
|
|
134
|
+
conn2 = described_class.get_connection(config.merge(sdk_key: 'key2'))
|
|
135
|
+
|
|
136
|
+
# Mock client counts
|
|
137
|
+
allow(conn1).to receive(:client_count).and_return(3)
|
|
138
|
+
allow(conn2).to receive(:client_count).and_return(2)
|
|
139
|
+
|
|
140
|
+
expect(described_class.total_client_count).to eq(5)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
describe '.has_connection?' do
|
|
145
|
+
it 'returns false for non-existent pool key' do
|
|
146
|
+
expect(described_class.has_connection?('missing-key')).to be false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'returns true for existing pool key' do
|
|
150
|
+
described_class.get_connection(config)
|
|
151
|
+
pool_key = described_class.generate_pool_key(config)
|
|
152
|
+
|
|
153
|
+
expect(described_class.has_connection?(pool_key)).to be true
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
describe '.get_connection_by_key' do
|
|
158
|
+
it 'returns nil for non-existent key' do
|
|
159
|
+
expect(described_class.get_connection_by_key('missing')).to be_nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'returns connection for existing key' do
|
|
163
|
+
connection = described_class.get_connection(config)
|
|
164
|
+
pool_key = described_class.generate_pool_key(config)
|
|
165
|
+
|
|
166
|
+
expect(described_class.get_connection_by_key(pool_key)).to eq(connection)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
describe '.set_debug' do
|
|
171
|
+
it 'enables debug on existing connections' do
|
|
172
|
+
connection = described_class.get_connection(config)
|
|
173
|
+
expect(connection).to receive(:set_debug).with(true)
|
|
174
|
+
|
|
175
|
+
described_class.set_debug(true)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe ToggleCraft::Evaluator do
|
|
4
|
+
let(:evaluator) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#update_flags' do
|
|
7
|
+
it 'stores flag data' do
|
|
8
|
+
flags = {
|
|
9
|
+
'test-flag' => { type: 'boolean', enabled: true, value: true }
|
|
10
|
+
}
|
|
11
|
+
evaluator.update_flags(flags)
|
|
12
|
+
expect(evaluator.has_flag?('test-flag')).to be true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'clears existing flags before updating' do
|
|
16
|
+
evaluator.update_flags({ 'flag1' => { type: 'boolean' } })
|
|
17
|
+
evaluator.update_flags({ 'flag2' => { type: 'boolean' } })
|
|
18
|
+
|
|
19
|
+
expect(evaluator.has_flag?('flag1')).to be false
|
|
20
|
+
expect(evaluator.has_flag?('flag2')).to be true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#evaluate_boolean' do
|
|
25
|
+
before do
|
|
26
|
+
evaluator.update_flags({
|
|
27
|
+
'simple-boolean' => {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
enabled: true,
|
|
30
|
+
value: true
|
|
31
|
+
},
|
|
32
|
+
'disabled-flag' => {
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
enabled: false,
|
|
35
|
+
value: true
|
|
36
|
+
},
|
|
37
|
+
'flag-with-rules' => {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
enabled: true,
|
|
40
|
+
value: false,
|
|
41
|
+
rules: [
|
|
42
|
+
{
|
|
43
|
+
conditions: [
|
|
44
|
+
{ attribute: 'user.role', operator: 'equals', values: ['admin'] }
|
|
45
|
+
],
|
|
46
|
+
value: true
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'returns true for enabled boolean flag' do
|
|
54
|
+
result = evaluator.evaluate_boolean('simple-boolean')
|
|
55
|
+
expect(result).to be true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns false for disabled flag' do
|
|
59
|
+
result = evaluator.evaluate_boolean('disabled-flag')
|
|
60
|
+
expect(result).to be false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'returns default value for non-existent flag' do
|
|
64
|
+
result = evaluator.evaluate_boolean('missing', {}, false)
|
|
65
|
+
expect(result).to be false
|
|
66
|
+
|
|
67
|
+
result = evaluator.evaluate_boolean('missing', {}, true)
|
|
68
|
+
expect(result).to be true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'evaluates targeting rules' do
|
|
72
|
+
admin_context = { user: { role: 'admin' } }
|
|
73
|
+
user_context = { user: { role: 'user' } }
|
|
74
|
+
|
|
75
|
+
expect(evaluator.evaluate_boolean('flag-with-rules', admin_context)).to be true
|
|
76
|
+
expect(evaluator.evaluate_boolean('flag-with-rules', user_context)).to be false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'warns and returns default for wrong flag type' do
|
|
80
|
+
evaluator.update_flags({
|
|
81
|
+
'multivariate-flag' => { type: 'multivariate', enabled: true }
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect do
|
|
85
|
+
result = evaluator.evaluate_boolean('multivariate-flag', {}, false)
|
|
86
|
+
expect(result).to be false
|
|
87
|
+
end.to output(/not a boolean flag/).to_stderr
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#evaluate_multivariate' do
|
|
92
|
+
before do
|
|
93
|
+
evaluator.update_flags({
|
|
94
|
+
'ab-test' => {
|
|
95
|
+
type: 'multivariate',
|
|
96
|
+
enabled: true,
|
|
97
|
+
default_variant: 'control',
|
|
98
|
+
variants: %w[control variant-a variant-b],
|
|
99
|
+
weights: { 'control' => 50, 'variant-a' => 30, 'variant-b' => 20 }
|
|
100
|
+
},
|
|
101
|
+
'disabled-multivariate' => {
|
|
102
|
+
type: 'multivariate',
|
|
103
|
+
enabled: false,
|
|
104
|
+
default_variant: 'control'
|
|
105
|
+
},
|
|
106
|
+
'rule-based-variant' => {
|
|
107
|
+
type: 'multivariate',
|
|
108
|
+
enabled: true,
|
|
109
|
+
default_variant: 'control',
|
|
110
|
+
rules: [
|
|
111
|
+
{
|
|
112
|
+
conditions: [
|
|
113
|
+
{ attribute: 'user.plan', operator: 'equals', values: ['premium'] }
|
|
114
|
+
],
|
|
115
|
+
variant: 'premium-variant'
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns variant based on weights with consistent hashing' do
|
|
123
|
+
context = { user: { id: 'user-123' } }
|
|
124
|
+
|
|
125
|
+
# Should return same variant for same user
|
|
126
|
+
variant1 = evaluator.evaluate_multivariate('ab-test', context)
|
|
127
|
+
variant2 = evaluator.evaluate_multivariate('ab-test', context)
|
|
128
|
+
|
|
129
|
+
expect(variant1).to eq(variant2)
|
|
130
|
+
expect(%w[control variant-a variant-b]).to include(variant1)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'returns default value parameter for disabled flag' do
|
|
134
|
+
result = evaluator.evaluate_multivariate('disabled-multivariate', {}, 'my-default')
|
|
135
|
+
expect(result).to eq('my-default')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'evaluates targeting rules' do
|
|
139
|
+
premium_context = { user: { plan: 'premium' } }
|
|
140
|
+
free_context = { user: { plan: 'free' } }
|
|
141
|
+
|
|
142
|
+
expect(evaluator.evaluate_multivariate('rule-based-variant', premium_context)).to eq('premium-variant')
|
|
143
|
+
expect(evaluator.evaluate_multivariate('rule-based-variant', free_context)).to eq('control')
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'returns default value for non-existent flag' do
|
|
147
|
+
result = evaluator.evaluate_multivariate('missing', {}, 'default')
|
|
148
|
+
expect(result).to eq('default')
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe '#evaluate_percentage' do
|
|
153
|
+
before do
|
|
154
|
+
evaluator.update_flags({
|
|
155
|
+
'rollout-50' => {
|
|
156
|
+
type: 'percentage',
|
|
157
|
+
enabled: true,
|
|
158
|
+
percentage: 50
|
|
159
|
+
},
|
|
160
|
+
'rollout-0' => {
|
|
161
|
+
type: 'percentage',
|
|
162
|
+
enabled: true,
|
|
163
|
+
percentage: 0
|
|
164
|
+
},
|
|
165
|
+
'rollout-100' => {
|
|
166
|
+
type: 'percentage',
|
|
167
|
+
enabled: true,
|
|
168
|
+
percentage: 100
|
|
169
|
+
},
|
|
170
|
+
'disabled-rollout' => {
|
|
171
|
+
type: 'percentage',
|
|
172
|
+
enabled: false,
|
|
173
|
+
percentage: 100
|
|
174
|
+
},
|
|
175
|
+
'rollout-with-rules' => {
|
|
176
|
+
type: 'percentage',
|
|
177
|
+
enabled: true,
|
|
178
|
+
percentage: 10,
|
|
179
|
+
rules: [
|
|
180
|
+
{
|
|
181
|
+
conditions: [
|
|
182
|
+
{ attribute: 'user.beta_tester', operator: 'equals', values: ['true'] }
|
|
183
|
+
],
|
|
184
|
+
enabled: true
|
|
185
|
+
}
|
|
186
|
+
]
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'returns false for 0% rollout' do
|
|
192
|
+
result = evaluator.evaluate_percentage('rollout-0', { user: { id: '123' } })
|
|
193
|
+
expect(result).to be false
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'returns true for 100% rollout' do
|
|
197
|
+
result = evaluator.evaluate_percentage('rollout-100', { user: { id: '123' } })
|
|
198
|
+
expect(result).to be true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'returns consistent results for same user' do
|
|
202
|
+
context = { user: { id: 'user-456' } }
|
|
203
|
+
|
|
204
|
+
result1 = evaluator.evaluate_percentage('rollout-50', context)
|
|
205
|
+
result2 = evaluator.evaluate_percentage('rollout-50', context)
|
|
206
|
+
|
|
207
|
+
expect(result1).to eq(result2)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'distributes users across percentage' do
|
|
211
|
+
# Test with many users to verify distribution
|
|
212
|
+
users_in_rollout = 0
|
|
213
|
+
100.times do |i|
|
|
214
|
+
context = { user: { id: "user-#{i}" } }
|
|
215
|
+
users_in_rollout += 1 if evaluator.evaluate_percentage('rollout-50', context)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Should be approximately 50% (allow 20% variance for small sample)
|
|
219
|
+
expect(users_in_rollout).to be_between(30, 70)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it 'returns false for disabled flag' do
|
|
223
|
+
result = evaluator.evaluate_percentage('disabled-rollout', { user: { id: '123' } })
|
|
224
|
+
expect(result).to be false
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'evaluates targeting rules' do
|
|
228
|
+
beta_context = { user: { id: '1', beta_tester: 'true' } }
|
|
229
|
+
normal_context = { user: { id: '2', beta_tester: 'false' } }
|
|
230
|
+
|
|
231
|
+
expect(evaluator.evaluate_percentage('rollout-with-rules', beta_context)).to be true
|
|
232
|
+
# Normal user depends on hash, just verify it doesn't error
|
|
233
|
+
evaluator.evaluate_percentage('rollout-with-rules', normal_context)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
describe '#current_percentage_for_flag' do
|
|
238
|
+
it 'returns base percentage when no rollout stages' do
|
|
239
|
+
flag = { type: 'percentage', percentage: 50, rollout_stages: [] }
|
|
240
|
+
expect(evaluator.current_percentage_for_flag(flag)).to eq(50)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'returns active stage percentage when stage is active' do
|
|
244
|
+
now = Time.now
|
|
245
|
+
flag = {
|
|
246
|
+
type: 'percentage',
|
|
247
|
+
percentage: 10,
|
|
248
|
+
rollout_stages: [
|
|
249
|
+
{
|
|
250
|
+
percentage: 50,
|
|
251
|
+
start_at: (now - 3600).iso8601, # Started 1 hour ago
|
|
252
|
+
end_at: (now + 3600).iso8601 # Ends in 1 hour
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
expect(evaluator.current_percentage_for_flag(flag)).to eq(50)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
it 'returns base percentage when no stage is active' do
|
|
261
|
+
now = Time.now
|
|
262
|
+
flag = {
|
|
263
|
+
type: 'percentage',
|
|
264
|
+
percentage: 10,
|
|
265
|
+
rollout_stages: [
|
|
266
|
+
{
|
|
267
|
+
percentage: 50,
|
|
268
|
+
start_at: (now + 3600).iso8601, # Starts in 1 hour
|
|
269
|
+
end_at: (now + 7200).iso8601 # Ends in 2 hours
|
|
270
|
+
}
|
|
271
|
+
]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
expect(evaluator.current_percentage_for_flag(flag)).to eq(10)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
it 'uses most recent active stage when multiple are active' do
|
|
278
|
+
now = Time.now
|
|
279
|
+
flag = {
|
|
280
|
+
type: 'percentage',
|
|
281
|
+
percentage: 10,
|
|
282
|
+
rollout_stages: [
|
|
283
|
+
{
|
|
284
|
+
percentage: 30,
|
|
285
|
+
start_at: (now - 7200).iso8601,
|
|
286
|
+
end_at: (now + 3600).iso8601
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
percentage: 50,
|
|
290
|
+
start_at: (now - 3600).iso8601,
|
|
291
|
+
end_at: (now + 3600).iso8601
|
|
292
|
+
}
|
|
293
|
+
]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
expect(evaluator.current_percentage_for_flag(flag)).to eq(50)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe '#evaluate_rule' do
|
|
301
|
+
it 'returns true when all AND conditions pass' do
|
|
302
|
+
rule = {
|
|
303
|
+
conditions: [
|
|
304
|
+
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'AND' },
|
|
305
|
+
{ attribute: 'user.active', operator: 'equals', values: ['true'], combinator: 'AND' }
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
context = { user: { role: 'admin', active: 'true' } }
|
|
309
|
+
|
|
310
|
+
expect(evaluator.evaluate_rule(rule, context)).to be true
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'returns false when any AND condition fails' do
|
|
314
|
+
rule = {
|
|
315
|
+
conditions: [
|
|
316
|
+
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'AND' },
|
|
317
|
+
{ attribute: 'user.active', operator: 'equals', values: ['true'], combinator: 'AND' }
|
|
318
|
+
]
|
|
319
|
+
}
|
|
320
|
+
context = { user: { role: 'admin', active: 'false' } }
|
|
321
|
+
|
|
322
|
+
expect(evaluator.evaluate_rule(rule, context)).to be false
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'returns true when any OR condition passes' do
|
|
326
|
+
rule = {
|
|
327
|
+
conditions: [
|
|
328
|
+
{ attribute: 'user.role', operator: 'equals', values: ['admin'], combinator: 'OR' },
|
|
329
|
+
{ attribute: 'user.role', operator: 'equals', values: ['superuser'], combinator: 'OR' }
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
context = { user: { role: 'superuser' } }
|
|
333
|
+
|
|
334
|
+
expect(evaluator.evaluate_rule(rule, context)).to be true
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it 'handles mixed AND/OR combinators' do
|
|
338
|
+
rule = {
|
|
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'] }
|
|
343
|
+
]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# admin OR (engineering AND level > 5)
|
|
347
|
+
# admin in engineering with level > 5 -> true (admin alone is enough)
|
|
348
|
+
context1 = { user: { role: 'admin', department: 'engineering', level: '6' } }
|
|
349
|
+
expect(evaluator.evaluate_rule(rule, context1)).to be true
|
|
350
|
+
|
|
351
|
+
# Not admin but in engineering with level > 5 -> true
|
|
352
|
+
context2 = { user: { role: 'user', department: 'engineering', level: '6' } }
|
|
353
|
+
expect(evaluator.evaluate_rule(rule, context2)).to be true
|
|
354
|
+
|
|
355
|
+
# admin but not in engineering -> true (admin alone is enough)
|
|
356
|
+
context3 = { user: { role: 'admin', department: 'sales', level: '3' } }
|
|
357
|
+
expect(evaluator.evaluate_rule(rule, context3)).to be true
|
|
358
|
+
|
|
359
|
+
# Not admin, in engineering but level <= 5 -> false
|
|
360
|
+
context4 = { user: { role: 'user', department: 'engineering', level: '3' } }
|
|
361
|
+
expect(evaluator.evaluate_rule(rule, context4)).to be false
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
it 'returns false for empty conditions' do
|
|
365
|
+
rule = { conditions: [] }
|
|
366
|
+
expect(evaluator.evaluate_rule(rule, {})).to be false
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
describe 'helper methods' do
|
|
371
|
+
before do
|
|
372
|
+
evaluator.update_flags({
|
|
373
|
+
'test-flag' => { type: 'boolean', enabled: true },
|
|
374
|
+
'another-flag' => { type: 'multivariate', enabled: true }
|
|
375
|
+
})
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
describe '#has_flag?' do
|
|
379
|
+
it 'returns true for existing flags' do
|
|
380
|
+
expect(evaluator.has_flag?('test-flag')).to be true
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
it 'returns false for non-existent flags' do
|
|
384
|
+
expect(evaluator.has_flag?('missing')).to be false
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
describe '#flag_keys' do
|
|
389
|
+
it 'returns all flag keys' do
|
|
390
|
+
keys = evaluator.flag_keys
|
|
391
|
+
expect(keys).to contain_exactly('test-flag', 'another-flag')
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
describe '#flag_metadata' do
|
|
396
|
+
it 'returns metadata for boolean flag' do
|
|
397
|
+
metadata = evaluator.flag_metadata('test-flag')
|
|
398
|
+
expect(metadata[:type]).to eq('boolean')
|
|
399
|
+
expect(metadata[:enabled]).to be true
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
it 'returns nil for non-existent flag' do
|
|
403
|
+
expect(evaluator.flag_metadata('missing')).to be_nil
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
describe '#evaluate' do
|
|
409
|
+
before do
|
|
410
|
+
evaluator.update_flags({
|
|
411
|
+
'bool-flag' => { type: 'boolean', enabled: true, value: true },
|
|
412
|
+
'multi-flag' => { type: 'multivariate', enabled: true, default_variant: 'control' },
|
|
413
|
+
'percent-flag' => { type: 'percentage', enabled: true, percentage: 100 }
|
|
414
|
+
})
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
it 'delegates to evaluate_boolean for boolean flags' do
|
|
418
|
+
result = evaluator.evaluate('bool-flag', {}, false)
|
|
419
|
+
expect(result).to be true
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
it 'delegates to evaluate_multivariate for multivariate flags' do
|
|
423
|
+
result = evaluator.evaluate('multi-flag', {}, nil)
|
|
424
|
+
expect(result).to eq('control')
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it 'delegates to evaluate_percentage for percentage flags' do
|
|
428
|
+
result = evaluator.evaluate('percent-flag', { user: { id: '123' } }, false)
|
|
429
|
+
expect(result).to be true
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
it 'returns default for unknown flag type' do
|
|
433
|
+
evaluator.update_flags({
|
|
434
|
+
'unknown-type' => { type: 'unknown', enabled: true }
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
expect do
|
|
438
|
+
result = evaluator.evaluate('unknown-type', {}, 'default')
|
|
439
|
+
expect(result).to eq('default')
|
|
440
|
+
end.to output(/Unknown flag type/).to_stderr
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
end
|