mixpanel-ruby 3.0.0 → 3.1.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,606 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Mixpanel::OpenFeature::Provider do
6
+ # --- Factory methods ---
7
+
8
+ describe '.from_local' do
9
+ it 'creates a provider with a local flags provider and starts polling' do
10
+ mock_local_flags = instance_double('LocalFlagsProvider')
11
+ allow(mock_local_flags).to receive(:start_polling_for_definitions!)
12
+
13
+ mock_tracker = instance_double('Mixpanel::Tracker', local_flags: mock_local_flags)
14
+ stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker))
15
+
16
+ config = { polling_interval: 300 }
17
+ provider = described_class.from_local('test-token', config)
18
+
19
+ expect(Mixpanel::Tracker).to have_received(:new).with('test-token', nil, local_flags_config: config)
20
+ expect(mock_local_flags).to have_received(:start_polling_for_definitions!)
21
+ expect(provider.mixpanel).to eq(mock_tracker)
22
+ expect(provider).to be_a(described_class)
23
+ end
24
+
25
+ it 'forwards error_handler to the tracker' do
26
+ mock_local_flags = instance_double('LocalFlagsProvider')
27
+ allow(mock_local_flags).to receive(:start_polling_for_definitions!)
28
+
29
+ mock_tracker = instance_double('Mixpanel::Tracker', local_flags: mock_local_flags)
30
+ stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker))
31
+
32
+ error_handler = double('ErrorHandler')
33
+ described_class.from_local('test-token', {}, error_handler: error_handler)
34
+
35
+ expect(Mixpanel::Tracker).to have_received(:new).with('test-token', error_handler, local_flags_config: {})
36
+ end
37
+ end
38
+
39
+ describe '.from_remote' do
40
+ it 'creates a provider with a remote flags provider' do
41
+ mock_remote_flags = double('RemoteFlagsProvider')
42
+ mock_tracker = instance_double('Mixpanel::Tracker', remote_flags: mock_remote_flags)
43
+ stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker))
44
+
45
+ config = { endpoint: 'https://example.com' }
46
+ provider = described_class.from_remote('test-token', config)
47
+
48
+ expect(Mixpanel::Tracker).to have_received(:new).with('test-token', nil, remote_flags_config: config)
49
+ expect(provider.mixpanel).to eq(mock_tracker)
50
+ expect(provider).to be_a(described_class)
51
+ end
52
+
53
+ it 'forwards error_handler to the tracker' do
54
+ mock_remote_flags = double('RemoteFlagsProvider')
55
+ mock_tracker = instance_double('Mixpanel::Tracker', remote_flags: mock_remote_flags)
56
+ stub_const('Mixpanel::Tracker', class_double('Mixpanel::Tracker', new: mock_tracker))
57
+
58
+ error_handler = double('ErrorHandler')
59
+ described_class.from_remote('test-token', {}, error_handler: error_handler)
60
+
61
+ expect(Mixpanel::Tracker).to have_received(:new).with('test-token', error_handler, remote_flags_config: {})
62
+ end
63
+ end
64
+
65
+ # --- Instance behavior ---
66
+
67
+ let(:mock_flags) do
68
+ instance_double('FlagsProvider').tap do |flags|
69
+ allow(flags).to receive(:are_flags_ready).and_return(true)
70
+ end
71
+ end
72
+
73
+ let(:provider) { described_class.new(mock_flags) }
74
+
75
+ def setup_flag(flag_key, value, variant_key: 'variant-key')
76
+ allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs|
77
+ if key == flag_key
78
+ Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value)
79
+ else
80
+ fallback
81
+ end
82
+ end
83
+ end
84
+
85
+ def setup_flag_not_found
86
+ allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback }
87
+ end
88
+
89
+ # --- Metadata ---
90
+
91
+ describe '#metadata' do
92
+ it 'returns mixpanel-provider as the name' do
93
+ expect(provider.metadata.name).to eq('mixpanel-provider')
94
+ end
95
+ end
96
+
97
+ # --- Boolean evaluation ---
98
+
99
+ describe '#fetch_boolean_value' do
100
+ it 'resolves true' do
101
+ setup_flag('bool-flag', true)
102
+ result = provider.fetch_boolean_value(flag_key: 'bool-flag', default_value: false)
103
+ expect(result.value).to be true
104
+ expect(result.reason).to eq('TARGETING_MATCH')
105
+ expect(result.error_code).to be_nil
106
+ end
107
+
108
+ it 'resolves false' do
109
+ setup_flag('bool-flag', false)
110
+ result = provider.fetch_boolean_value(flag_key: 'bool-flag', default_value: true)
111
+ expect(result.value).to be false
112
+ expect(result.reason).to eq('TARGETING_MATCH')
113
+ end
114
+
115
+ it 'returns TYPE_MISMATCH when value is not boolean' do
116
+ setup_flag('string-flag', 'not-a-bool')
117
+ result = provider.fetch_boolean_value(flag_key: 'string-flag', default_value: false)
118
+ expect(result.value).to be false
119
+ expect(result.error_code).to eq('TYPE_MISMATCH')
120
+ expect(result.reason).to eq('ERROR')
121
+ end
122
+ end
123
+
124
+ # --- String evaluation ---
125
+
126
+ describe '#fetch_string_value' do
127
+ it 'resolves a string' do
128
+ setup_flag('string-flag', 'hello')
129
+ result = provider.fetch_string_value(flag_key: 'string-flag', default_value: 'default')
130
+ expect(result.value).to eq('hello')
131
+ expect(result.reason).to eq('TARGETING_MATCH')
132
+ expect(result.error_code).to be_nil
133
+ end
134
+
135
+ it 'returns TYPE_MISMATCH when value is not string' do
136
+ setup_flag('bool-flag', true)
137
+ result = provider.fetch_string_value(flag_key: 'bool-flag', default_value: 'default')
138
+ expect(result.value).to eq('default')
139
+ expect(result.error_code).to eq('TYPE_MISMATCH')
140
+ expect(result.reason).to eq('ERROR')
141
+ end
142
+ end
143
+
144
+ # --- Integer evaluation ---
145
+
146
+ describe '#fetch_integer_value' do
147
+ it 'resolves an integer' do
148
+ setup_flag('int-flag', 42)
149
+ result = provider.fetch_integer_value(flag_key: 'int-flag', default_value: 0)
150
+ expect(result.value).to eq(42)
151
+ expect(result.reason).to eq('TARGETING_MATCH')
152
+ expect(result.error_code).to be_nil
153
+ end
154
+
155
+ it 'coerces float with no fraction to integer' do
156
+ setup_flag('int-flag', 42.0)
157
+ result = provider.fetch_integer_value(flag_key: 'int-flag', default_value: 0)
158
+ expect(result.value).to eq(42)
159
+ expect(result.value).to be_a(Integer)
160
+ expect(result.reason).to eq('TARGETING_MATCH')
161
+ end
162
+
163
+ it 'returns TYPE_MISMATCH for float with fraction' do
164
+ setup_flag('float-flag', 3.14)
165
+ result = provider.fetch_integer_value(flag_key: 'float-flag', default_value: 0)
166
+ expect(result.value).to eq(0)
167
+ expect(result.error_code).to eq('TYPE_MISMATCH')
168
+ expect(result.reason).to eq('ERROR')
169
+ end
170
+
171
+ it 'returns TYPE_MISMATCH when value is a string' do
172
+ setup_flag('string-flag', 'not-a-number')
173
+ result = provider.fetch_integer_value(flag_key: 'string-flag', default_value: 0)
174
+ expect(result.value).to eq(0)
175
+ expect(result.error_code).to eq('TYPE_MISMATCH')
176
+ expect(result.reason).to eq('ERROR')
177
+ end
178
+ end
179
+
180
+ # --- Float evaluation ---
181
+
182
+ describe '#fetch_float_value' do
183
+ it 'resolves a float' do
184
+ setup_flag('float-flag', 3.14)
185
+ result = provider.fetch_float_value(flag_key: 'float-flag', default_value: 0.0)
186
+ expect(result.value).to be_within(0.001).of(3.14)
187
+ expect(result.reason).to eq('TARGETING_MATCH')
188
+ expect(result.error_code).to be_nil
189
+ end
190
+
191
+ it 'coerces integer to float' do
192
+ setup_flag('float-flag', 42)
193
+ result = provider.fetch_float_value(flag_key: 'float-flag', default_value: 0.0)
194
+ expect(result.value).to eq(42.0)
195
+ expect(result.value).to be_a(Float)
196
+ expect(result.reason).to eq('TARGETING_MATCH')
197
+ end
198
+
199
+ it 'returns TYPE_MISMATCH when value is a string' do
200
+ setup_flag('string-flag', 'not-a-number')
201
+ result = provider.fetch_float_value(flag_key: 'string-flag', default_value: 0.0)
202
+ expect(result.value).to eq(0.0)
203
+ expect(result.error_code).to eq('TYPE_MISMATCH')
204
+ expect(result.reason).to eq('ERROR')
205
+ end
206
+ end
207
+
208
+ # --- Number evaluation ---
209
+
210
+ describe '#fetch_number_value' do
211
+ it 'resolves an integer as number' do
212
+ setup_flag('num-flag', 42)
213
+ result = provider.fetch_number_value(flag_key: 'num-flag', default_value: 0)
214
+ expect(result.value).to eq(42)
215
+ expect(result.reason).to eq('TARGETING_MATCH')
216
+ end
217
+
218
+ it 'resolves a float as number' do
219
+ setup_flag('num-flag', 3.14)
220
+ result = provider.fetch_number_value(flag_key: 'num-flag', default_value: 0.0)
221
+ expect(result.value).to be_within(0.001).of(3.14)
222
+ expect(result.reason).to eq('TARGETING_MATCH')
223
+ end
224
+
225
+ it 'returns TYPE_MISMATCH when value is not numeric' do
226
+ setup_flag('string-flag', 'hello')
227
+ result = provider.fetch_number_value(flag_key: 'string-flag', default_value: 0)
228
+ expect(result.value).to eq(0)
229
+ expect(result.error_code).to eq('TYPE_MISMATCH')
230
+ expect(result.reason).to eq('ERROR')
231
+ end
232
+ end
233
+
234
+ # --- Object evaluation ---
235
+
236
+ describe '#fetch_object_value' do
237
+ it 'resolves a hash' do
238
+ setup_flag('obj-flag', { 'key' => 'value' })
239
+ result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {})
240
+ expect(result.value).to eq({ 'key' => 'value' })
241
+ expect(result.reason).to eq('TARGETING_MATCH')
242
+ expect(result.error_code).to be_nil
243
+ end
244
+
245
+ it 'resolves an array' do
246
+ setup_flag('obj-flag', [1, 2, 3])
247
+ result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: [])
248
+ expect(result.value).to eq([1, 2, 3])
249
+ expect(result.reason).to eq('TARGETING_MATCH')
250
+ end
251
+
252
+ it 'resolves a string as object' do
253
+ setup_flag('obj-flag', 'hello')
254
+ result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {})
255
+ expect(result.value).to eq('hello')
256
+ expect(result.reason).to eq('TARGETING_MATCH')
257
+ end
258
+
259
+ it 'resolves a boolean as object' do
260
+ setup_flag('obj-flag', true)
261
+ result = provider.fetch_object_value(flag_key: 'obj-flag', default_value: {})
262
+ expect(result.value).to be true
263
+ expect(result.reason).to eq('TARGETING_MATCH')
264
+ end
265
+ end
266
+
267
+ # --- FLAG_NOT_FOUND ---
268
+
269
+ describe 'flag not found' do
270
+ before { setup_flag_not_found }
271
+
272
+ it 'returns FLAG_NOT_FOUND for boolean' do
273
+ result = provider.fetch_boolean_value(flag_key: 'missing', default_value: true)
274
+ expect(result.value).to be true
275
+ expect(result.error_code).to eq('FLAG_NOT_FOUND')
276
+ expect(result.reason).to eq('DEFAULT')
277
+ end
278
+
279
+ it 'returns FLAG_NOT_FOUND for string' do
280
+ result = provider.fetch_string_value(flag_key: 'missing', default_value: 'fallback')
281
+ expect(result.value).to eq('fallback')
282
+ expect(result.error_code).to eq('FLAG_NOT_FOUND')
283
+ expect(result.reason).to eq('DEFAULT')
284
+ end
285
+
286
+ it 'returns FLAG_NOT_FOUND for integer' do
287
+ result = provider.fetch_integer_value(flag_key: 'missing', default_value: 99)
288
+ expect(result.value).to eq(99)
289
+ expect(result.error_code).to eq('FLAG_NOT_FOUND')
290
+ expect(result.reason).to eq('DEFAULT')
291
+ end
292
+
293
+ it 'returns FLAG_NOT_FOUND for float' do
294
+ result = provider.fetch_float_value(flag_key: 'missing', default_value: 1.5)
295
+ expect(result.value).to eq(1.5)
296
+ expect(result.error_code).to eq('FLAG_NOT_FOUND')
297
+ expect(result.reason).to eq('DEFAULT')
298
+ end
299
+
300
+ it 'returns FLAG_NOT_FOUND for object' do
301
+ result = provider.fetch_object_value(flag_key: 'missing', default_value: { 'default' => true })
302
+ expect(result.value).to eq({ 'default' => true })
303
+ expect(result.error_code).to eq('FLAG_NOT_FOUND')
304
+ expect(result.reason).to eq('DEFAULT')
305
+ end
306
+ end
307
+
308
+ # --- PROVIDER_NOT_READY ---
309
+
310
+ describe 'provider not ready' do
311
+ let(:mock_flags) do
312
+ instance_double('FlagsProvider').tap do |flags|
313
+ allow(flags).to receive(:are_flags_ready).and_return(false)
314
+ end
315
+ end
316
+
317
+ it 'returns PROVIDER_NOT_READY for boolean' do
318
+ result = provider.fetch_boolean_value(flag_key: 'any', default_value: true)
319
+ expect(result.value).to be true
320
+ expect(result.error_code).to eq('PROVIDER_NOT_READY')
321
+ expect(result.reason).to eq('ERROR')
322
+ end
323
+
324
+ it 'returns PROVIDER_NOT_READY for string' do
325
+ result = provider.fetch_string_value(flag_key: 'any', default_value: 'default')
326
+ expect(result.value).to eq('default')
327
+ expect(result.error_code).to eq('PROVIDER_NOT_READY')
328
+ expect(result.reason).to eq('ERROR')
329
+ end
330
+
331
+ it 'returns PROVIDER_NOT_READY for integer' do
332
+ result = provider.fetch_integer_value(flag_key: 'any', default_value: 5)
333
+ expect(result.value).to eq(5)
334
+ expect(result.error_code).to eq('PROVIDER_NOT_READY')
335
+ expect(result.reason).to eq('ERROR')
336
+ end
337
+
338
+ it 'returns PROVIDER_NOT_READY for float' do
339
+ result = provider.fetch_float_value(flag_key: 'any', default_value: 2.5)
340
+ expect(result.value).to eq(2.5)
341
+ expect(result.error_code).to eq('PROVIDER_NOT_READY')
342
+ expect(result.reason).to eq('ERROR')
343
+ end
344
+
345
+ it 'returns PROVIDER_NOT_READY for object' do
346
+ result = provider.fetch_object_value(flag_key: 'any', default_value: { 'default' => true })
347
+ expect(result.value).to eq({ 'default' => true })
348
+ expect(result.error_code).to eq('PROVIDER_NOT_READY')
349
+ expect(result.reason).to eq('ERROR')
350
+ end
351
+ end
352
+
353
+ # --- Remote provider (no are_flags_ready) is always ready ---
354
+
355
+ describe 'remote provider without are_flags_ready' do
356
+ let(:remote_flags) do
357
+ double('RemoteFlagsProvider').tap do |flags|
358
+ allow(flags).to receive(:get_variant) do |_key, _fallback, _ctx|
359
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
360
+ end
361
+ end
362
+ end
363
+
364
+ let(:provider) { described_class.new(remote_flags) }
365
+
366
+ it 'treats provider as ready' do
367
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
368
+ expect(result.value).to be true
369
+ expect(result.reason).to eq('TARGETING_MATCH')
370
+ end
371
+ end
372
+
373
+ # --- Variant key passthrough ---
374
+
375
+ describe 'variant key' do
376
+ it 'includes variant key in successful resolution' do
377
+ setup_flag('flag', 'value', variant_key: 'my-variant')
378
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
379
+ expect(result.variant).to eq('my-variant')
380
+ expect(result.reason).to eq('TARGETING_MATCH')
381
+ end
382
+
383
+ it 'does not include variant on error' do
384
+ setup_flag_not_found
385
+ result = provider.fetch_string_value(flag_key: 'missing', default_value: 'default')
386
+ expect(result.variant).to be_nil
387
+ end
388
+ end
389
+
390
+ # --- Context forwarding ---
391
+
392
+ describe 'evaluation context forwarding' do
393
+ it 'forwards evaluation_context fields to get_variant' do
394
+ eval_context = double('EvaluationContext',
395
+ fields: { 'distinct_id' => 'user-1', 'plan' => 'premium' },
396
+ targeting_key: nil
397
+ )
398
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
399
+ expect(ctx).to eq({ 'distinct_id' => 'user-1', 'plan' => 'premium' })
400
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
401
+ end
402
+
403
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
404
+ end
405
+
406
+ it 'includes targeting_key in context when present' do
407
+ eval_context = double('EvaluationContext',
408
+ fields: { 'distinct_id' => 'user-1' },
409
+ targeting_key: 'tk-123'
410
+ )
411
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
412
+ expect(ctx).to eq({ 'distinct_id' => 'user-1', 'targetingKey' => 'tk-123' })
413
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
414
+ end
415
+
416
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
417
+ end
418
+
419
+ it 'coerces whole floats to integers in context' do
420
+ eval_context = double('EvaluationContext',
421
+ fields: { 'distinct_id' => 'user-1', 'age' => 30.0 },
422
+ targeting_key: nil
423
+ )
424
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
425
+ expect(ctx).to eq({ 'distinct_id' => 'user-1', 'age' => 30 })
426
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
427
+ end
428
+
429
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
430
+ end
431
+
432
+ it 'preserves fractional floats in context' do
433
+ eval_context = double('EvaluationContext',
434
+ fields: { 'score' => 3.14 },
435
+ targeting_key: nil
436
+ )
437
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
438
+ expect(ctx).to eq({ 'score' => 3.14 })
439
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
440
+ end
441
+
442
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
443
+ end
444
+
445
+ it 'recursively unwraps arrays in context' do
446
+ eval_context = double('EvaluationContext',
447
+ fields: { 'tags' => ['a', 'b', 'c'] },
448
+ targeting_key: nil
449
+ )
450
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
451
+ expect(ctx).to eq({ 'tags' => ['a', 'b', 'c'] })
452
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
453
+ end
454
+
455
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
456
+ end
457
+
458
+ it 'recursively unwraps nested hashes in context' do
459
+ eval_context = double('EvaluationContext',
460
+ fields: { 'meta' => { 'nested_float' => 5.0, 'name' => 'test' } },
461
+ targeting_key: nil
462
+ )
463
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
464
+ expect(ctx).to eq({ 'meta' => { 'nested_float' => 5, 'name' => 'test' } })
465
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
466
+ end
467
+
468
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false, evaluation_context: eval_context)
469
+ end
470
+
471
+ it 'passes empty hash when evaluation_context is nil' do
472
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, ctx|
473
+ expect(ctx).to eq({})
474
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
475
+ end
476
+
477
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
478
+ end
479
+ end
480
+
481
+ # --- SDK exception handling ---
482
+
483
+ describe 'SDK exception handling' do
484
+ it 'returns default value with GENERAL error when get_variant raises' do
485
+ allow(mock_flags).to receive(:get_variant).and_raise(RuntimeError, 'unexpected SDK error')
486
+
487
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true)
488
+ expect(result.value).to be true
489
+ expect(result.error_code).to eq('GENERAL')
490
+ expect(result.reason).to eq('ERROR')
491
+ end
492
+
493
+ it 'returns default value for string when get_variant raises' do
494
+ allow(mock_flags).to receive(:get_variant).and_raise(StandardError, 'connection failed')
495
+
496
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'fallback')
497
+ expect(result.value).to eq('fallback')
498
+ expect(result.error_code).to eq('GENERAL')
499
+ expect(result.reason).to eq('ERROR')
500
+ end
501
+
502
+ it 'returns default value for integer when get_variant raises' do
503
+ allow(mock_flags).to receive(:get_variant).and_raise(StandardError, 'timeout')
504
+
505
+ result = provider.fetch_integer_value(flag_key: 'flag', default_value: 42)
506
+ expect(result.value).to eq(42)
507
+ expect(result.error_code).to eq('GENERAL')
508
+ expect(result.reason).to eq('ERROR')
509
+ end
510
+ end
511
+
512
+ # --- Null variant key ---
513
+
514
+ describe 'null variant key' do
515
+ it 'resolves successfully with nil variant when variant_key is nil' do
516
+ setup_flag('flag', 'hello', variant_key: nil)
517
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
518
+ expect(result.value).to eq('hello')
519
+ expect(result.variant).to be_nil
520
+ expect(result.reason).to eq('TARGETING_MATCH')
521
+ expect(result.error_code).to be_nil
522
+ end
523
+
524
+ it 'resolves boolean with nil variant key' do
525
+ setup_flag('flag', true, variant_key: nil)
526
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
527
+ expect(result.value).to be true
528
+ expect(result.variant).to be_nil
529
+ expect(result.reason).to eq('TARGETING_MATCH')
530
+ end
531
+ end
532
+
533
+ # --- Nil variant value ---
534
+
535
+ describe 'nil variant value' do
536
+ it 'returns TYPE_MISMATCH for boolean when variant value is nil' do
537
+ setup_flag('nil-flag', nil)
538
+ result = provider.fetch_boolean_value(flag_key: 'nil-flag', default_value: false)
539
+ expect(result.value).to be false
540
+ expect(result.error_code).to eq('TYPE_MISMATCH')
541
+ expect(result.reason).to eq('ERROR')
542
+ end
543
+
544
+ it 'returns TYPE_MISMATCH for string when variant value is nil' do
545
+ setup_flag('nil-flag', nil)
546
+ result = provider.fetch_string_value(flag_key: 'nil-flag', default_value: 'default')
547
+ expect(result.value).to eq('default')
548
+ expect(result.error_code).to eq('TYPE_MISMATCH')
549
+ expect(result.reason).to eq('ERROR')
550
+ end
551
+
552
+ it 'returns TYPE_MISMATCH for integer when variant value is nil' do
553
+ setup_flag('nil-flag', nil)
554
+ result = provider.fetch_integer_value(flag_key: 'nil-flag', default_value: 0)
555
+ expect(result.value).to eq(0)
556
+ expect(result.error_code).to eq('TYPE_MISMATCH')
557
+ expect(result.reason).to eq('ERROR')
558
+ end
559
+
560
+ it 'returns TYPE_MISMATCH for float when variant value is nil' do
561
+ setup_flag('nil-flag', nil)
562
+ result = provider.fetch_float_value(flag_key: 'nil-flag', default_value: 0.0)
563
+ expect(result.value).to eq(0.0)
564
+ expect(result.error_code).to eq('TYPE_MISMATCH')
565
+ expect(result.reason).to eq('ERROR')
566
+ end
567
+
568
+ it 'returns TYPE_MISMATCH for number when variant value is nil' do
569
+ setup_flag('nil-flag', nil)
570
+ result = provider.fetch_number_value(flag_key: 'nil-flag', default_value: 0)
571
+ expect(result.value).to eq(0)
572
+ expect(result.error_code).to eq('TYPE_MISMATCH')
573
+ expect(result.reason).to eq('ERROR')
574
+ end
575
+
576
+ it 'allows nil variant value for object type' do
577
+ setup_flag('nil-flag', nil)
578
+ result = provider.fetch_object_value(flag_key: 'nil-flag', default_value: {})
579
+ expect(result.value).to be_nil
580
+ expect(result.reason).to eq('TARGETING_MATCH')
581
+ expect(result.error_code).to be_nil
582
+ end
583
+ end
584
+
585
+ # --- Exposure reporting ---
586
+
587
+ describe 'exposure reporting' do
588
+ it 'calls get_variant with report_exposure: true' do
589
+ allow(mock_flags).to receive(:get_variant) do |_key, _fallback, _ctx, report_exposure:|
590
+ expect(report_exposure).to be true
591
+ Mixpanel::Flags::SelectedVariant.new(variant_key: 'v1', variant_value: true)
592
+ end
593
+ provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
594
+ end
595
+ end
596
+
597
+ # --- Lifecycle ---
598
+
599
+ describe '#shutdown' do
600
+ it 'delegates to the flags provider' do
601
+ allow(mock_flags).to receive(:shutdown)
602
+ provider.shutdown
603
+ expect(mock_flags).to have_received(:shutdown)
604
+ end
605
+ end
606
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ require 'simplecov-cobertura'
5
+
6
+ SimpleCov.start do
7
+ add_filter '/spec/'
8
+
9
+ formatter SimpleCov::Formatter::MultiFormatter.new([
10
+ SimpleCov::Formatter::HTMLFormatter,
11
+ SimpleCov::Formatter::CoberturaFormatter
12
+ ])
13
+ end
14
+
15
+ require 'mixpanel/openfeature'
16
+ require 'mixpanel-ruby/flags/types'
17
+
18
+ RSpec.configure do |config|
19
+ config.run_all_when_everything_filtered = true
20
+ config.filter_run :focus
21
+ config.raise_errors_for_deprecations!
22
+ config.order = 'random'
23
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mixpanel-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mixpanel
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-05 00:00:00.000000000 Z
11
+ date: 2026-04-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: mutex_m
@@ -196,6 +196,14 @@ files:
196
196
  - lib/mixpanel-ruby/tracker.rb
197
197
  - lib/mixpanel-ruby/version.rb
198
198
  - mixpanel-ruby.gemspec
199
+ - openfeature-provider/Gemfile
200
+ - openfeature-provider/README.md
201
+ - openfeature-provider/RELEASE.md
202
+ - openfeature-provider/lib/mixpanel/openfeature.rb
203
+ - openfeature-provider/lib/mixpanel/openfeature/provider.rb
204
+ - openfeature-provider/mixpanel-ruby-openfeature.gemspec
205
+ - openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb
206
+ - openfeature-provider/spec/spec_helper.rb
199
207
  - spec/mixpanel-ruby/consumer_spec.rb
200
208
  - spec/mixpanel-ruby/error_spec.rb
201
209
  - spec/mixpanel-ruby/events_spec.rb
@@ -225,7 +233,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
225
233
  - !ruby/object:Gem::Version
226
234
  version: '0'
227
235
  requirements: []
228
- rubygems_version: 3.5.16
236
+ rubygems_version: 3.5.22
229
237
  signing_key:
230
238
  specification_version: 4
231
239
  summary: Official Mixpanel tracking library for ruby