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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +31 -2
- data/lib/mixpanel-ruby/flags/flags_provider.rb +12 -8
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +19 -22
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/openfeature-provider/Gemfile +7 -0
- data/openfeature-provider/README.md +286 -0
- data/openfeature-provider/RELEASE.md +52 -0
- data/openfeature-provider/lib/mixpanel/openfeature/provider.rb +170 -0
- data/openfeature-provider/lib/mixpanel/openfeature.rb +3 -0
- data/openfeature-provider/mixpanel-ruby-openfeature.gemspec +23 -0
- data/openfeature-provider/spec/mixpanel_openfeature_provider_spec.rb +606 -0
- data/openfeature-provider/spec/spec_helper.rb +23 -0
- metadata +11 -3
|
@@ -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.
|
|
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-
|
|
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.
|
|
236
|
+
rubygems_version: 3.5.22
|
|
229
237
|
signing_key:
|
|
230
238
|
specification_version: 4
|
|
231
239
|
summary: Official Mixpanel tracking library for ruby
|