mixpanel-ruby 2.3.0 → 3.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,759 @@
1
+ require 'json'
2
+ require 'mixpanel-ruby/flags/local_flags_provider'
3
+ require 'mixpanel-ruby/flags/types'
4
+ require 'webmock/rspec'
5
+
6
+ describe Mixpanel::Flags::LocalFlagsProvider do
7
+ let(:test_token) { 'test-token' }
8
+ let(:test_context) { { 'distinct_id' => 'user123' } }
9
+ let(:endpoint_url_regex) { %r{https://api\.mixpanel\.com/flags/definitions} }
10
+ let(:mock_tracker) { double('tracker').as_null_object }
11
+ let(:mock_error_handler) { double('error_handler', handle: nil) }
12
+ let(:config) { { enable_polling: false } }
13
+
14
+ let(:provider) do
15
+ Mixpanel::Flags::LocalFlagsProvider.new(
16
+ test_token,
17
+ config,
18
+ mock_tracker,
19
+ mock_error_handler
20
+ )
21
+ end
22
+
23
+ before(:each) do
24
+ WebMock.reset!
25
+ WebMock.disable_net_connect!(allow_localhost: false)
26
+ end
27
+
28
+ after(:each) do
29
+ provider.stop_polling_for_definitions!
30
+ end
31
+
32
+ def create_test_flag(options = {})
33
+ flag_key = options[:flag_key] || 'test_flag'
34
+ context = options[:context] || 'distinct_id'
35
+ variants = options[:variants] || [
36
+ { 'key' => 'control', 'value' => 'control', 'is_control' => true, 'split' => 50.0 },
37
+ { 'key' => 'treatment', 'value' => 'treatment', 'is_control' => false, 'split' => 50.0 }
38
+ ]
39
+ variant_override = options[:variant_override]
40
+ rollout_percentage = options[:rollout_percentage] || 100.0
41
+ runtime_evaluation_rule = options[:runtime_evaluation_rule]
42
+ test_users = options[:test_users]
43
+ experiment_id = options[:experiment_id]
44
+ is_experiment_active = options[:is_experiment_active]
45
+ variant_splits = options[:variant_splits]
46
+ hash_salt = options[:hash_salt]
47
+
48
+ rollout = [
49
+ {
50
+ 'rollout_percentage' => rollout_percentage,
51
+ 'runtime_evaluation_rule' => runtime_evaluation_rule,
52
+ 'variant_override' => variant_override,
53
+ 'variant_splits' => variant_splits
54
+ }.compact
55
+ ]
56
+
57
+ test_config = test_users ? { 'users' => test_users } : nil
58
+
59
+ {
60
+ 'id' => 'test-id',
61
+ 'name' => 'Test Flag',
62
+ 'key' => flag_key,
63
+ 'status' => 'active',
64
+ 'project_id' => 123,
65
+ 'context' => context,
66
+ 'experiment_id' => experiment_id,
67
+ 'is_experiment_active' => is_experiment_active,
68
+ 'hash_salt' => hash_salt,
69
+ 'ruleset' => {
70
+ 'variants' => variants,
71
+ 'rollout' => rollout,
72
+ 'test' => test_config
73
+ }.compact
74
+ }.compact
75
+ end
76
+
77
+ def stub_flag_definitions(flags)
78
+ response = {
79
+ code: 200,
80
+ flags: flags
81
+ }
82
+
83
+ stub_request(:get, endpoint_url_regex)
84
+ .to_return(
85
+ status: 200,
86
+ body: response.to_json,
87
+ headers: { 'Content-Type' => 'application/json' }
88
+ )
89
+ end
90
+
91
+ def stub_flag_definitions_failure(status_code)
92
+ stub_request(:get, endpoint_url_regex)
93
+ .to_return(status: status_code)
94
+ end
95
+
96
+ def user_context_with_properties(properties)
97
+ {
98
+ 'distinct_id' => 'user123',
99
+ 'custom_properties' => properties
100
+ }
101
+ end
102
+
103
+ describe '#get_variant_value' do
104
+ it 'returns fallback when no flag definitions' do
105
+ stub_flag_definitions([])
106
+ provider.start_polling_for_definitions!
107
+
108
+ result = provider.get_variant_value('nonexistent_flag', 'control', test_context)
109
+ expect(result).to eq('control')
110
+ expect(mock_tracker).not_to have_received(:call)
111
+ end
112
+
113
+ it 'returns fallback if flag definition call fails' do
114
+ stub_flag_definitions_failure(500)
115
+ provider.start_polling_for_definitions!
116
+
117
+ result = provider.get_variant_value('nonexistent_flag', 'control', test_context)
118
+ expect(result).to eq('control')
119
+ end
120
+
121
+ it 'returns fallback when flag does not exist' do
122
+ other_flag = create_test_flag(flag_key: 'other_flag')
123
+ stub_flag_definitions([other_flag])
124
+ provider.start_polling_for_definitions!
125
+
126
+ result = provider.get_variant_value('nonexistent_flag', 'control', test_context)
127
+ expect(result).to eq('control')
128
+ end
129
+
130
+ it 'returns fallback when no context' do
131
+ flag = create_test_flag(context: 'distinct_id')
132
+ stub_flag_definitions([flag])
133
+ provider.start_polling_for_definitions!
134
+
135
+ result = provider.get_variant_value('test_flag', 'fallback', {})
136
+ expect(result).to eq('fallback')
137
+ end
138
+
139
+ it 'returns fallback when wrong context key' do
140
+ flag = create_test_flag(context: 'user_id')
141
+ stub_flag_definitions([flag])
142
+ provider.start_polling_for_definitions!
143
+
144
+ result = provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'user123' })
145
+ expect(result).to eq('fallback')
146
+ end
147
+
148
+ it 'returns test user variant when configured' do
149
+ variants = [
150
+ { 'key' => 'control', 'value' => 'false', 'is_control' => true, 'split' => 50.0 },
151
+ { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 50.0 }
152
+ ]
153
+ flag = create_test_flag(
154
+ variants: variants,
155
+ test_users: { 'test_user' => 'treatment' }
156
+ )
157
+
158
+ stub_flag_definitions([flag])
159
+ provider.start_polling_for_definitions!
160
+
161
+ result = provider.get_variant_value('test_flag', 'control', { 'distinct_id' => 'test_user' })
162
+ expect(result).to eq('true')
163
+ end
164
+
165
+ it 'returns correct variant when test user variant not configured' do
166
+ variants = [
167
+ { 'key' => 'control', 'value' => 'false', 'is_control' => true, 'split' => 50.0 },
168
+ { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 50.0 }
169
+ ]
170
+ flag = create_test_flag(
171
+ variants: variants,
172
+ test_users: { 'test_user' => 'nonexistent_variant' }
173
+ )
174
+
175
+ stub_flag_definitions([flag])
176
+ provider.start_polling_for_definitions!
177
+
178
+ result = provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'test_user' })
179
+ expect(['false', 'true']).to include(result)
180
+ end
181
+
182
+ it 'returns fallback when rollout percentage zero' do
183
+ flag = create_test_flag(rollout_percentage: 0.0)
184
+ stub_flag_definitions([flag])
185
+ provider.start_polling_for_definitions!
186
+
187
+ result = provider.get_variant_value('test_flag', 'fallback', test_context)
188
+ expect(result).to eq('fallback')
189
+ end
190
+
191
+ it 'returns variant when rollout percentage hundred' do
192
+ flag = create_test_flag(rollout_percentage: 100.0)
193
+ stub_flag_definitions([flag])
194
+ provider.start_polling_for_definitions!
195
+
196
+ result = provider.get_variant_value('test_flag', 'fallback', test_context)
197
+ expect(result).not_to eq('fallback')
198
+ expect(['control', 'treatment']).to include(result)
199
+ end
200
+
201
+ it 'respects runtime evaluation rule with equality operator when satisfied' do
202
+ runtime_eval = {
203
+ '==' => [{'var' => 'plan'}, 'premium']
204
+ }
205
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
206
+
207
+ stub_flag_definitions([flag])
208
+ provider.start_polling_for_definitions!
209
+
210
+ context = user_context_with_properties({'plan' => 'premium'})
211
+ result = provider.get_variant_value('test_flag', 'fallback', context)
212
+
213
+ expect(result).not_to eq('fallback')
214
+ expect(['control', 'treatment']).to include(result)
215
+ end
216
+
217
+ it 'respects runtime evaluation rule with equality operator when not satisfied' do
218
+ runtime_eval = {
219
+ '==' => [{'var' => 'plan'}, 'premium']
220
+ }
221
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
222
+
223
+ stub_flag_definitions([flag])
224
+ provider.start_polling_for_definitions!
225
+
226
+ context = user_context_with_properties({'plan' => 'basic'})
227
+ result = provider.get_variant_value('test_flag', 'fallback', context)
228
+
229
+ expect(result).to eq('fallback')
230
+ end
231
+
232
+ it 'returns fallback when runtime rule is invalid' do
233
+ runtime_eval = {
234
+ '=oops=' => [{'var' => 'plan'}, 'premium']
235
+ }
236
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
237
+
238
+ stub_flag_definitions([flag])
239
+ provider.start_polling_for_definitions!
240
+
241
+ context = user_context_with_properties({'plan' => 'premium'})
242
+ result = provider.get_variant_value('test_flag', 'fallback', context)
243
+
244
+ expect(result).to eq('fallback')
245
+ end
246
+
247
+ it 'returns fallback when runtime evaluation rule used but no custom properties provided' do
248
+ runtime_eval = {
249
+ '==' => [{'var' => 'plan'}, 'premium']
250
+ }
251
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
252
+
253
+ stub_flag_definitions([flag])
254
+ provider.start_polling_for_definitions!
255
+
256
+ context = {'distinct_id' => 'user123', 'custom_properties' => {}}
257
+ result = provider.get_variant_value('test_flag', 'fallback', context)
258
+
259
+ expect(result).to eq('fallback')
260
+ end
261
+
262
+ it 'respects runtime evaluation rule case-insensitive param value when satisfied' do
263
+ runtime_eval = {
264
+ '==' => [{'var' => 'plan'}, 'premium']
265
+ }
266
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
267
+
268
+ stub_flag_definitions([flag])
269
+ provider.start_polling_for_definitions!
270
+
271
+ context = user_context_with_properties({'plan' => 'PremIum'})
272
+ result = provider.get_variant_value('test_flag', 'fallback', context)
273
+
274
+ expect(result).not_to eq('fallback')
275
+ expect(['control', 'treatment']).to include(result)
276
+ end
277
+
278
+ it 'respects runtime evaluation rule case-insensitive var names when satisfied' do
279
+ runtime_eval = {
280
+ '==' => [{'var' => 'Plan'}, 'premium']
281
+ }
282
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
283
+
284
+ stub_flag_definitions([flag])
285
+ provider.start_polling_for_definitions!
286
+
287
+ context = user_context_with_properties({'plan' => 'premium'})
288
+ result = provider.get_variant_value('test_flag', 'fallback', context)
289
+
290
+ expect(result).not_to eq('fallback')
291
+ expect(['control', 'treatment']).to include(result)
292
+ end
293
+
294
+ it 'respects runtime evaluation rule case-insensitive rule value when satisfied' do
295
+ runtime_eval = {
296
+ '==' => [{'var' => 'plan'}, 'pREMIUm']
297
+ }
298
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
299
+
300
+ stub_flag_definitions([flag])
301
+ provider.start_polling_for_definitions!
302
+
303
+ context = user_context_with_properties({'plan' => 'premium'})
304
+ result = provider.get_variant_value('test_flag', 'fallback', context)
305
+
306
+ expect(result).not_to eq('fallback')
307
+ expect(['control', 'treatment']).to include(result)
308
+ end
309
+
310
+ it 'respects runtime evaluation rule with contains operator when satisfied' do
311
+ runtime_eval = {
312
+ 'in' => ['Springfield', {'var' => 'url'}]
313
+ }
314
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
315
+
316
+ stub_flag_definitions([flag])
317
+ provider.start_polling_for_definitions!
318
+
319
+ context = user_context_with_properties({'url' => 'https://helloworld.com/Springfield/all-about-it'})
320
+ result = provider.get_variant_value('test_flag', 'fallback', context)
321
+
322
+ expect(result).not_to eq('fallback')
323
+ expect(['control', 'treatment']).to include(result)
324
+ end
325
+
326
+ it 'respects runtime evaluation rule with contains operator when not satisfied' do
327
+ runtime_eval = {
328
+ 'in' => ['Springfield', {'var' => 'url'}]
329
+ }
330
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
331
+
332
+ stub_flag_definitions([flag])
333
+ provider.start_polling_for_definitions!
334
+
335
+ context = user_context_with_properties({'url' => 'https://helloworld.com/Boston/all-about-it'})
336
+ result = provider.get_variant_value('test_flag', 'fallback', context)
337
+
338
+ expect(result).to eq('fallback')
339
+ end
340
+
341
+ it 'respects runtime evaluation rule with multi-value in operator when satisfied' do
342
+ runtime_eval = {
343
+ 'in' => [
344
+ {'var' => 'name'},
345
+ ['a', 'b', 'c', 'all-from-the-ui']
346
+ ]
347
+ }
348
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
349
+
350
+ stub_flag_definitions([flag])
351
+ provider.start_polling_for_definitions!
352
+
353
+ context = user_context_with_properties({'name' => 'b'})
354
+ result = provider.get_variant_value('test_flag', 'fallback', context)
355
+
356
+ expect(result).not_to eq('fallback')
357
+ expect(['control', 'treatment']).to include(result)
358
+ end
359
+
360
+ it 'respects runtime evaluation rule with multi-value in operator when not satisfied' do
361
+ runtime_eval = {
362
+ 'in' => [
363
+ {'var' => 'name'},
364
+ ['a', 'b', 'c', 'all-from-the-ui']
365
+ ]
366
+ }
367
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
368
+
369
+ stub_flag_definitions([flag])
370
+ provider.start_polling_for_definitions!
371
+
372
+ context = user_context_with_properties({'name' => 'd'})
373
+ result = provider.get_variant_value('test_flag', 'fallback', context)
374
+
375
+ expect(result).to eq('fallback')
376
+ end
377
+
378
+ it 'respects runtime evaluation rule with AND operator when satisfied' do
379
+ runtime_eval = {
380
+ 'and' => [
381
+ {'==' => [{'var' => 'name'}, 'Johannes']},
382
+ {'==' => [{'var' => 'country'}, 'Deutschland']}
383
+ ]
384
+ }
385
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
386
+
387
+ stub_flag_definitions([flag])
388
+ provider.start_polling_for_definitions!
389
+
390
+ context = user_context_with_properties({
391
+ 'name' => 'Johannes',
392
+ 'country' => 'Deutschland'
393
+ })
394
+ result = provider.get_variant_value('test_flag', 'fallback', context)
395
+
396
+ expect(result).not_to eq('fallback')
397
+ expect(['control', 'treatment']).to include(result)
398
+ end
399
+
400
+ it 'respects runtime evaluation rule with AND operator when not satisfied' do
401
+ runtime_eval = {
402
+ 'and' => [
403
+ {'==' => [{'var' => 'name'}, 'Johannes']},
404
+ {'==' => [{'var' => 'country'}, 'Deutschland']}
405
+ ]
406
+ }
407
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
408
+
409
+ stub_flag_definitions([flag])
410
+ provider.start_polling_for_definitions!
411
+
412
+ context = user_context_with_properties({
413
+ 'name' => 'Johannes',
414
+ 'country' => 'France'
415
+ })
416
+ result = provider.get_variant_value('test_flag', 'fallback', context)
417
+
418
+ expect(result).to eq('fallback')
419
+ end
420
+
421
+ it 'respects runtime evaluation rule with comparison operator when satisfied' do
422
+ runtime_eval = {
423
+ '>' => [
424
+ {'var' => 'queries_ran'},
425
+ 25
426
+ ]
427
+ }
428
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
429
+
430
+ stub_flag_definitions([flag])
431
+ provider.start_polling_for_definitions!
432
+
433
+ context = user_context_with_properties({'queries_ran' => 30})
434
+ result = provider.get_variant_value('test_flag', 'fallback', context)
435
+
436
+ expect(result).not_to eq('fallback')
437
+ expect(['control', 'treatment']).to include(result)
438
+ end
439
+
440
+ it 'respects runtime evaluation rule with comparison operator when not satisfied' do
441
+ runtime_eval = {
442
+ '>' => [
443
+ {'var' => 'queries_ran'},
444
+ 25
445
+ ]
446
+ }
447
+ flag = create_test_flag(runtime_evaluation_rule: runtime_eval)
448
+
449
+ stub_flag_definitions([flag])
450
+ provider.start_polling_for_definitions!
451
+
452
+ context = user_context_with_properties({'queries_ran' => 20})
453
+ result = provider.get_variant_value('test_flag', 'fallback', context)
454
+
455
+ expect(result).to eq('fallback')
456
+ end
457
+
458
+ it 'picks correct variant with hundred percent split' do
459
+ variants = [
460
+ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },
461
+ { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 },
462
+ { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false, 'split' => 0.0 }
463
+ ]
464
+ flag = create_test_flag(
465
+ variants: variants,
466
+ rollout_percentage: 100.0
467
+ )
468
+
469
+ stub_flag_definitions([flag])
470
+ provider.start_polling_for_definitions!
471
+
472
+ result = provider.get_variant_value('test_flag', 'fallback', test_context)
473
+ expect(result).to eq('variant_a')
474
+ end
475
+
476
+ it 'picks correct variant with half migrated group splits' do
477
+ variants = [
478
+ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },
479
+ { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 },
480
+ { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false, 'split' => 0.0 }
481
+ ]
482
+ variant_splits = { 'A' => 0.0, 'B' => 100.0, 'C' => 0.0 }
483
+ flag = create_test_flag(
484
+ variants: variants,
485
+ rollout_percentage: 100.0,
486
+ variant_splits: variant_splits
487
+ )
488
+
489
+ stub_flag_definitions([flag])
490
+ provider.start_polling_for_definitions!
491
+
492
+ result = provider.get_variant_value('test_flag', 'fallback', test_context)
493
+ expect(result).to eq('variant_b')
494
+ end
495
+
496
+ it 'picks correct variant with full migrated group splits' do
497
+ variants = [
498
+ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false },
499
+ { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false },
500
+ { 'key' => 'C', 'value' => 'variant_c', 'is_control' => false }
501
+ ]
502
+ variant_splits = { 'A' => 0.0, 'B' => 0.0, 'C' => 100.0 }
503
+ flag = create_test_flag(
504
+ variants: variants,
505
+ rollout_percentage: 100.0,
506
+ variant_splits: variant_splits
507
+ )
508
+
509
+ stub_flag_definitions([flag])
510
+ provider.start_polling_for_definitions!
511
+
512
+ result = provider.get_variant_value('test_flag', 'fallback', test_context)
513
+ expect(result).to eq('variant_c')
514
+ end
515
+
516
+ it 'picks overridden variant' do
517
+ variants = [
518
+ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },
519
+ { 'key' => 'B', 'value' => 'variant_b', 'is_control' => false, 'split' => 0.0 }
520
+ ]
521
+ flag = create_test_flag(
522
+ variants: variants,
523
+ variant_override: { 'key' => 'B' }
524
+ )
525
+
526
+ stub_flag_definitions([flag])
527
+ provider.start_polling_for_definitions!
528
+
529
+ result = provider.get_variant_value('test_flag', 'control', test_context)
530
+ expect(result).to eq('variant_b')
531
+ end
532
+
533
+ it 'tracks exposure when variant selected' do
534
+ flag = create_test_flag
535
+ stub_flag_definitions([flag])
536
+ provider.start_polling_for_definitions!
537
+
538
+ expect(mock_tracker).to receive(:call).once
539
+
540
+ provider.get_variant_value('test_flag', 'fallback', test_context)
541
+ end
542
+
543
+ it 'tracks exposure with correct properties' do
544
+ flag = create_test_flag(
545
+ experiment_id: 'exp-123',
546
+ is_experiment_active: true,
547
+ test_users: { 'qa_user' => 'treatment' }
548
+ )
549
+
550
+ stub_flag_definitions([flag])
551
+ provider.start_polling_for_definitions!
552
+
553
+ expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties|
554
+ expect(distinct_id).to eq('qa_user')
555
+ expect(event_name).to eq('$experiment_started')
556
+ expect(properties['$experiment_id']).to eq('exp-123')
557
+ expect(properties['$is_experiment_active']).to eq(true)
558
+ expect(properties['$is_qa_tester']).to eq(true)
559
+ end
560
+
561
+ provider.get_variant_value('test_flag', 'fallback', { 'distinct_id' => 'qa_user' })
562
+ end
563
+
564
+ it 'does not track exposure on fallback' do
565
+ stub_flag_definitions([])
566
+ provider.start_polling_for_definitions!
567
+
568
+ expect(mock_tracker).not_to receive(:call)
569
+
570
+ provider.get_variant_value('nonexistent_flag', 'fallback', test_context)
571
+ end
572
+
573
+ it 'does not track exposure without distinct_id' do
574
+ flag = create_test_flag(context: 'company')
575
+ stub_flag_definitions([flag])
576
+ provider.start_polling_for_definitions!
577
+
578
+ expect(mock_tracker).not_to receive(:call)
579
+
580
+ provider.get_variant_value('test_flag', 'fallback', { 'company_id' => 'company123' })
581
+ end
582
+ end
583
+
584
+ describe '#get_variant' do
585
+ it 'returns fallback variant when no flag definitions' do
586
+ stub_flag_definitions([])
587
+ provider.start_polling_for_definitions!
588
+
589
+ fallback = Mixpanel::Flags::SelectedVariant.new(variant_value: 'control')
590
+ result = provider.get_variant('nonexistent_flag', fallback, test_context)
591
+
592
+ expect(result.variant_value).to eq('control')
593
+ expect(mock_tracker).not_to have_received(:call)
594
+ end
595
+
596
+ it 'returns variant with correct properties' do
597
+ flag = create_test_flag(rollout_percentage: 100.0)
598
+ stub_flag_definitions([flag])
599
+ provider.start_polling_for_definitions!
600
+
601
+ fallback = Mixpanel::Flags::SelectedVariant.new(variant_value: 'fallback')
602
+ result = provider.get_variant('test_flag', fallback, test_context, report_exposure: false)
603
+
604
+ expect(['control', 'treatment']).to include(result.variant_key)
605
+ expect(['control', 'treatment']).to include(result.variant_value)
606
+ end
607
+ end
608
+
609
+ describe '#get_all_variants' do
610
+ it 'returns empty hash when no flag definitions' do
611
+ stub_flag_definitions([])
612
+ provider.start_polling_for_definitions!
613
+
614
+ result = provider.get_all_variants(test_context)
615
+
616
+ expect(result).to eq({})
617
+ end
618
+
619
+ it 'returns all variants when two flags have 100% rollout' do
620
+ flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0)
621
+ flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 100.0)
622
+
623
+ stub_flag_definitions([flag1, flag2])
624
+ provider.start_polling_for_definitions!
625
+
626
+ result = provider.get_all_variants(test_context)
627
+
628
+ expect(result.keys).to contain_exactly('flag1', 'flag2')
629
+ end
630
+
631
+ it 'returns partial results when one flag has 0% rollout' do
632
+ flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0)
633
+ flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 0.0)
634
+
635
+ stub_flag_definitions([flag1, flag2])
636
+ provider.start_polling_for_definitions!
637
+
638
+ result = provider.get_all_variants(test_context)
639
+
640
+ expect(result.keys).to include('flag1')
641
+ expect(result.keys).not_to include('flag2')
642
+ end
643
+
644
+ it 'does not track exposure events' do
645
+ flag1 = create_test_flag(flag_key: 'flag1', rollout_percentage: 100.0)
646
+ flag2 = create_test_flag(flag_key: 'flag2', rollout_percentage: 100.0)
647
+
648
+ stub_flag_definitions([flag1, flag2])
649
+ provider.start_polling_for_definitions!
650
+
651
+ expect(mock_tracker).not_to receive(:call)
652
+
653
+ provider.get_all_variants(test_context)
654
+ end
655
+ end
656
+
657
+ describe '#is_enabled' do
658
+ it 'returns false for nonexistent flag' do
659
+ stub_flag_definitions([])
660
+ provider.start_polling_for_definitions!
661
+
662
+ result = provider.is_enabled?('nonexistent_flag', test_context)
663
+ expect(result).to eq(false)
664
+ end
665
+
666
+ it 'returns true for true variant value' do
667
+ variants = [
668
+ { 'key' => 'treatment', 'value' => true, 'is_control' => false, 'split' => 100.0 }
669
+ ]
670
+ flag = create_test_flag(variants: variants, rollout_percentage: 100.0)
671
+
672
+ stub_flag_definitions([flag])
673
+ provider.start_polling_for_definitions!
674
+
675
+ result = provider.is_enabled?('test_flag', test_context)
676
+ expect(result).to eq(true)
677
+ end
678
+
679
+ it 'returns false for false variant value' do
680
+ variants = [
681
+ { 'key' => 'control', 'value' => false, 'is_control' => true, 'split' => 100.0 }
682
+ ]
683
+ flag = create_test_flag(variants: variants, rollout_percentage: 100.0)
684
+
685
+ stub_flag_definitions([flag])
686
+ provider.start_polling_for_definitions!
687
+
688
+ result = provider.is_enabled?('test_flag', test_context)
689
+ expect(result).to eq(false)
690
+ end
691
+
692
+ it 'returns false for truthy non-boolean values' do
693
+ variants = [
694
+ { 'key' => 'treatment', 'value' => 'true', 'is_control' => false, 'split' => 100.0 }
695
+ ]
696
+ flag = create_test_flag(variants: variants, rollout_percentage: 100.0)
697
+
698
+ stub_flag_definitions([flag])
699
+ provider.start_polling_for_definitions!
700
+
701
+ result = provider.is_enabled?('test_flag', test_context)
702
+ expect(result).to eq(false)
703
+ end
704
+ end
705
+
706
+ describe '#track_exposure_event' do
707
+ it 'successfully tracks' do
708
+ flag = create_test_flag
709
+ stub_flag_definitions([flag])
710
+ provider.start_polling_for_definitions!
711
+
712
+ variant = Mixpanel::Flags::SelectedVariant.new(
713
+ variant_key: 'treatment',
714
+ variant_value: 'treatment'
715
+ )
716
+
717
+ expect(mock_tracker).to receive(:call).once
718
+
719
+ provider.send(:track_exposure_event, 'test_flag', variant, test_context)
720
+ end
721
+ end
722
+
723
+ describe 'polling' do
724
+ it 'uses most recent polled flag definitions' do
725
+ flag_v1 = create_test_flag(rollout_percentage: 0.0)
726
+ flag_v2 = create_test_flag(rollout_percentage: 100.0)
727
+
728
+ call_count = 0
729
+ stub_request(:get, endpoint_url_regex)
730
+ .to_return do |request|
731
+ call_count += 1
732
+ flag = call_count == 1 ? flag_v1 : flag_v2
733
+ {
734
+ status: 200,
735
+ body: { code: 200, flags: [flag] }.to_json,
736
+ headers: { 'Content-Type' => 'application/json' }
737
+ }
738
+ end
739
+
740
+ polling_provider = Mixpanel::Flags::LocalFlagsProvider.new(
741
+ test_token,
742
+ { enable_polling: true, polling_interval_in_seconds: 0.1 },
743
+ mock_tracker,
744
+ mock_error_handler
745
+ )
746
+
747
+ begin
748
+ polling_provider.start_polling_for_definitions!
749
+
750
+ sleep 0.3
751
+
752
+ result = polling_provider.get_variant_value('test_flag', 'fallback', test_context, report_exposure: false)
753
+ expect(result).not_to eq('fallback')
754
+ ensure
755
+ polling_provider.stop_polling_for_definitions!
756
+ end
757
+ end
758
+ end
759
+ end