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.
@@ -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