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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +37 -0
- data/Readme.rdoc +8 -2
- data/demo/flags/local_flags.rb +25 -0
- data/demo/flags/remote_flags.rb +18 -0
- data/lib/mixpanel-ruby/events.rb +2 -2
- data/lib/mixpanel-ruby/flags/flags_provider.rb +111 -0
- data/lib/mixpanel-ruby/flags/local_flags_provider.rb +303 -0
- data/lib/mixpanel-ruby/flags/remote_flags_provider.rb +134 -0
- data/lib/mixpanel-ruby/flags/types.rb +35 -0
- data/lib/mixpanel-ruby/flags/utils.rb +65 -0
- data/lib/mixpanel-ruby/groups.rb +1 -1
- data/lib/mixpanel-ruby/people.rb +1 -1
- data/lib/mixpanel-ruby/tracker.rb +32 -2
- data/lib/mixpanel-ruby/version.rb +1 -1
- data/lib/mixpanel-ruby.rb +5 -0
- data/mixpanel-ruby.gemspec +10 -3
- data/spec/mixpanel-ruby/events_spec.rb +2 -2
- data/spec/mixpanel-ruby/flags/local_flags_spec.rb +759 -0
- data/spec/mixpanel-ruby/flags/remote_flags_spec.rb +441 -0
- data/spec/mixpanel-ruby/flags/utils_spec.rb +110 -0
- data/spec/mixpanel-ruby/groups_spec.rb +10 -10
- data/spec/mixpanel-ruby/tracker_spec.rb +5 -5
- data/spec/spec_helper.rb +14 -0
- metadata +117 -9
- data/.travis.yml +0 -8
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'mixpanel-ruby/flags/remote_flags_provider'
|
|
3
|
+
require 'mixpanel-ruby/flags/types'
|
|
4
|
+
require 'webmock/rspec'
|
|
5
|
+
|
|
6
|
+
describe Mixpanel::Flags::RemoteFlagsProvider 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} }
|
|
10
|
+
let(:mock_tracker) { double('tracker').as_null_object }
|
|
11
|
+
let(:mock_error_handler) { double('error_handler', handle: nil) }
|
|
12
|
+
let(:config) { {} }
|
|
13
|
+
|
|
14
|
+
let(:provider) do
|
|
15
|
+
Mixpanel::Flags::RemoteFlagsProvider.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
|
+
def create_success_response(flags_with_selected_variant)
|
|
29
|
+
{
|
|
30
|
+
code: 200,
|
|
31
|
+
flags: flags_with_selected_variant
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stub_flags_request(response_body)
|
|
36
|
+
stub_request(:get, endpoint_url_regex)
|
|
37
|
+
.to_return(
|
|
38
|
+
status: 200,
|
|
39
|
+
body: response_body.to_json,
|
|
40
|
+
headers: { 'Content-Type' => 'application/json' }
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stub_flags_request_failure(status_code)
|
|
46
|
+
stub_request(:get, endpoint_url_regex)
|
|
47
|
+
.with(basic_auth: [test_token, ''])
|
|
48
|
+
.to_return(status: status_code)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stub_flags_request_error(error)
|
|
52
|
+
stub_request(:get, endpoint_url_regex)
|
|
53
|
+
.with(basic_auth: [test_token, ''])
|
|
54
|
+
.to_raise(error)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#get_variant_value' do
|
|
58
|
+
it 'returns fallback value if call fails' do
|
|
59
|
+
stub_flags_request_error(Errno::ECONNREFUSED)
|
|
60
|
+
|
|
61
|
+
result = provider.get_variant_value('test_flag', 'control', test_context)
|
|
62
|
+
expect(result).to eq('control')
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns fallback value if bad response format' do
|
|
66
|
+
stub_request(:get, %r{api\.mixpanel\.com/flags})
|
|
67
|
+
.to_return(
|
|
68
|
+
status: 200,
|
|
69
|
+
body: 'invalid json',
|
|
70
|
+
headers: { 'Content-Type' => 'text/plain' }
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = provider.get_variant_value('test_flag', 'control', test_context)
|
|
74
|
+
expect(result).to eq('control')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns fallback value if success but no flag found' do
|
|
78
|
+
stub_flags_request(create_success_response({}))
|
|
79
|
+
|
|
80
|
+
result = provider.get_variant_value('test_flag', 'control', test_context)
|
|
81
|
+
expect(result).to eq('control')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'returns expected variant from API' do
|
|
85
|
+
response = create_success_response({
|
|
86
|
+
'test_flag' => {
|
|
87
|
+
'variant_key' => 'treatment',
|
|
88
|
+
'variant_value' => 'treatment'
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
stub_flags_request(response)
|
|
92
|
+
|
|
93
|
+
result = provider.get_variant_value('test_flag', 'control', test_context)
|
|
94
|
+
expect(result).to eq('treatment')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'tracks exposure event if variant selected' do
|
|
98
|
+
response = create_success_response({
|
|
99
|
+
'test_flag' => {
|
|
100
|
+
'variant_key' => 'treatment',
|
|
101
|
+
'variant_value' => 'treatment'
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
stub_flags_request(response)
|
|
105
|
+
|
|
106
|
+
expect(mock_tracker).to receive(:call).once
|
|
107
|
+
|
|
108
|
+
provider.get_variant_value('test_flag', 'control', test_context)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'does not track exposure event if fallback' do
|
|
112
|
+
stub_flags_request_error(Errno::ECONNREFUSED)
|
|
113
|
+
|
|
114
|
+
expect(mock_tracker).not_to receive(:call)
|
|
115
|
+
|
|
116
|
+
provider.get_variant_value('test_flag', 'control', test_context)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'does not track exposure event when report_exposure is false' do
|
|
120
|
+
response = create_success_response({
|
|
121
|
+
'test_flag' => {
|
|
122
|
+
'variant_key' => 'treatment',
|
|
123
|
+
'variant_value' => 'treatment'
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
stub_flags_request(response)
|
|
127
|
+
|
|
128
|
+
expect(mock_tracker).not_to receive(:call)
|
|
129
|
+
|
|
130
|
+
provider.get_variant_value('test_flag', 'control', test_context, report_exposure: false)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'handles different variant value types' do
|
|
134
|
+
# Test string
|
|
135
|
+
response = create_success_response({
|
|
136
|
+
'string_flag' => {
|
|
137
|
+
'variant_key' => 'treatment',
|
|
138
|
+
'variant_value' => 'text-value'
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
stub_flags_request(response)
|
|
142
|
+
result = provider.get_variant_value('string_flag', 'default', test_context, report_exposure: false)
|
|
143
|
+
expect(result).to eq('text-value')
|
|
144
|
+
|
|
145
|
+
# Test number
|
|
146
|
+
WebMock.reset!
|
|
147
|
+
response = create_success_response({
|
|
148
|
+
'number_flag' => {
|
|
149
|
+
'variant_key' => 'treatment',
|
|
150
|
+
'variant_value' => 42
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
stub_flags_request(response)
|
|
154
|
+
result = provider.get_variant_value('number_flag', 0, test_context, report_exposure: false)
|
|
155
|
+
expect(result).to eq(42)
|
|
156
|
+
|
|
157
|
+
# Test object
|
|
158
|
+
WebMock.reset!
|
|
159
|
+
response = create_success_response({
|
|
160
|
+
'object_flag' => {
|
|
161
|
+
'variant_key' => 'treatment',
|
|
162
|
+
'variant_value' => { 'key' => 'value' }
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
stub_flags_request(response)
|
|
166
|
+
result = provider.get_variant_value('object_flag', {}, test_context, report_exposure: false)
|
|
167
|
+
expect(result).to eq({ 'key' => 'value' })
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '#get_variant' do
|
|
172
|
+
it 'returns variant when served' do
|
|
173
|
+
response = create_success_response({
|
|
174
|
+
'new-feature' => {
|
|
175
|
+
'variant_key' => 'on',
|
|
176
|
+
'variant_value' => true
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
stub_flags_request(response)
|
|
180
|
+
|
|
181
|
+
fallback_variant = Mixpanel::Flags::SelectedVariant.new(variant_value: false)
|
|
182
|
+
result = provider.get_variant('new-feature', fallback_variant, test_context, report_exposure: false)
|
|
183
|
+
|
|
184
|
+
expect(result.variant_key).to eq('on')
|
|
185
|
+
expect(result.variant_value).to eq(true)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'selects fallback variant when no flags are served' do
|
|
189
|
+
stub_flags_request(create_success_response({}))
|
|
190
|
+
|
|
191
|
+
fallback_variant = Mixpanel::Flags::SelectedVariant.new(
|
|
192
|
+
variant_key: 'control',
|
|
193
|
+
variant_value: false
|
|
194
|
+
)
|
|
195
|
+
result = provider.get_variant('any-flag', fallback_variant, test_context)
|
|
196
|
+
|
|
197
|
+
expect(result.variant_key).to eq('control')
|
|
198
|
+
expect(result.variant_value).to eq(false)
|
|
199
|
+
expect(mock_tracker).not_to have_received(:call)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'selects fallback variant if flag does not exist in served flags' do
|
|
203
|
+
response = create_success_response({
|
|
204
|
+
'different-flag' => {
|
|
205
|
+
'variant_key' => 'on',
|
|
206
|
+
'variant_value' => true
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
stub_flags_request(response)
|
|
210
|
+
|
|
211
|
+
fallback_variant = Mixpanel::Flags::SelectedVariant.new(
|
|
212
|
+
variant_key: 'control',
|
|
213
|
+
variant_value: false
|
|
214
|
+
)
|
|
215
|
+
result = provider.get_variant('missing-flag', fallback_variant, test_context)
|
|
216
|
+
|
|
217
|
+
expect(result.variant_key).to eq('control')
|
|
218
|
+
expect(result.variant_value).to eq(false)
|
|
219
|
+
expect(mock_tracker).not_to have_received(:call)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
it 'tracks exposure event when variant is selected' do
|
|
223
|
+
response = create_success_response({
|
|
224
|
+
'test-flag' => {
|
|
225
|
+
'variant_key' => 'treatment',
|
|
226
|
+
'variant_value' => true
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
stub_flags_request(response)
|
|
230
|
+
|
|
231
|
+
fallback_variant = Mixpanel::Flags::SelectedVariant.new(
|
|
232
|
+
variant_key: 'control',
|
|
233
|
+
variant_value: false
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties|
|
|
237
|
+
expect(distinct_id).to eq('user123')
|
|
238
|
+
expect(event_name).to eq('$experiment_started')
|
|
239
|
+
expect(properties['Experiment name']).to eq('test-flag')
|
|
240
|
+
expect(properties['Variant name']).to eq('treatment')
|
|
241
|
+
expect(properties['$experiment_type']).to eq('feature_flag')
|
|
242
|
+
expect(properties['Flag evaluation mode']).to eq('remote')
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
provider.get_variant('test-flag', fallback_variant, test_context)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'does not track exposure event when fallback variant is selected' do
|
|
249
|
+
stub_flags_request(create_success_response({}))
|
|
250
|
+
|
|
251
|
+
fallback_variant = Mixpanel::Flags::SelectedVariant.new(
|
|
252
|
+
variant_key: 'control',
|
|
253
|
+
variant_value: false
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
expect(mock_tracker).not_to receive(:call)
|
|
257
|
+
|
|
258
|
+
provider.get_variant('any-flag', fallback_variant, test_context)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
describe '#is_enabled' do
|
|
263
|
+
it 'returns true when variant value is boolean true' do
|
|
264
|
+
response = create_success_response({
|
|
265
|
+
'test_flag' => {
|
|
266
|
+
'variant_key' => 'on',
|
|
267
|
+
'variant_value' => true
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
stub_flags_request(response)
|
|
271
|
+
|
|
272
|
+
result = provider.is_enabled?('test_flag', test_context)
|
|
273
|
+
expect(result).to eq(true)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it 'returns false when variant value is boolean false' do
|
|
277
|
+
response = create_success_response({
|
|
278
|
+
'test_flag' => {
|
|
279
|
+
'variant_key' => 'off',
|
|
280
|
+
'variant_value' => false
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
stub_flags_request(response)
|
|
284
|
+
|
|
285
|
+
result = provider.is_enabled?('test_flag', test_context)
|
|
286
|
+
expect(result).to eq(false)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it 'returns false for truthy non-boolean values' do
|
|
290
|
+
# Test string "true"
|
|
291
|
+
response = create_success_response({
|
|
292
|
+
'string_flag' => {
|
|
293
|
+
'variant_key' => 'treatment',
|
|
294
|
+
'variant_value' => 'true'
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
stub_flags_request(response)
|
|
298
|
+
|
|
299
|
+
expect(mock_tracker).to receive(:call).once
|
|
300
|
+
result = provider.is_enabled?('string_flag', test_context)
|
|
301
|
+
expect(result).to eq(false)
|
|
302
|
+
|
|
303
|
+
# Test number 1
|
|
304
|
+
WebMock.reset!
|
|
305
|
+
response = create_success_response({
|
|
306
|
+
'number_flag' => {
|
|
307
|
+
'variant_key' => 'treatment',
|
|
308
|
+
'variant_value' => 1
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
stub_flags_request(response)
|
|
312
|
+
|
|
313
|
+
expect(mock_tracker).to receive(:call).once
|
|
314
|
+
result = provider.is_enabled?('number_flag', test_context)
|
|
315
|
+
expect(result).to eq(false)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
it 'returns false when flag does not exist' do
|
|
319
|
+
response = create_success_response({
|
|
320
|
+
'different-flag' => {
|
|
321
|
+
'variant_key' => 'on',
|
|
322
|
+
'variant_value' => true
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
stub_flags_request(response)
|
|
326
|
+
|
|
327
|
+
result = provider.is_enabled?('missing-flag', test_context)
|
|
328
|
+
expect(result).to eq(false)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'tracks exposure event' do
|
|
332
|
+
response = create_success_response({
|
|
333
|
+
'test_flag' => {
|
|
334
|
+
'variant_key' => 'on',
|
|
335
|
+
'variant_value' => true
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
stub_flags_request(response)
|
|
339
|
+
|
|
340
|
+
expect(mock_tracker).to receive(:call) do |distinct_id, event_name, properties|
|
|
341
|
+
expect(event_name).to eq('$experiment_started')
|
|
342
|
+
expect(properties['Experiment name']).to eq('test_flag')
|
|
343
|
+
expect(properties['Variant name']).to eq('on')
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
provider.is_enabled?('test_flag', test_context)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'returns false on network error' do
|
|
350
|
+
stub_flags_request_error(Errno::ECONNREFUSED)
|
|
351
|
+
|
|
352
|
+
result = provider.is_enabled?('test_flag', test_context)
|
|
353
|
+
expect(result).to eq(false)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
describe '#get_all_variants' do
|
|
358
|
+
it 'returns all variants from API' do
|
|
359
|
+
response = create_success_response({
|
|
360
|
+
'flag-1' => {
|
|
361
|
+
'variant_key' => 'treatment',
|
|
362
|
+
'variant_value' => true
|
|
363
|
+
},
|
|
364
|
+
'flag-2' => {
|
|
365
|
+
'variant_key' => 'control',
|
|
366
|
+
'variant_value' => false
|
|
367
|
+
},
|
|
368
|
+
'flag-3' => {
|
|
369
|
+
'variant_key' => 'blue',
|
|
370
|
+
'variant_value' => 'blue-theme'
|
|
371
|
+
}
|
|
372
|
+
})
|
|
373
|
+
stub_flags_request(response)
|
|
374
|
+
|
|
375
|
+
result = provider.get_all_variants(test_context)
|
|
376
|
+
|
|
377
|
+
expect(result.keys).to contain_exactly('flag-1', 'flag-2', 'flag-3')
|
|
378
|
+
expect(result['flag-1'].variant_key).to eq('treatment')
|
|
379
|
+
expect(result['flag-1'].variant_value).to eq(true)
|
|
380
|
+
expect(result['flag-2'].variant_key).to eq('control')
|
|
381
|
+
expect(result['flag-2'].variant_value).to eq(false)
|
|
382
|
+
expect(result['flag-3'].variant_key).to eq('blue')
|
|
383
|
+
expect(result['flag-3'].variant_value).to eq('blue-theme')
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it 'returns empty hash when no flags served' do
|
|
387
|
+
stub_flags_request(create_success_response({}))
|
|
388
|
+
|
|
389
|
+
result = provider.get_all_variants(test_context)
|
|
390
|
+
|
|
391
|
+
expect(result).to eq({})
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
it 'does not track any exposure events' do
|
|
395
|
+
response = create_success_response({
|
|
396
|
+
'flag-1' => {
|
|
397
|
+
'variant_key' => 'treatment',
|
|
398
|
+
'variant_value' => true
|
|
399
|
+
},
|
|
400
|
+
'flag-2' => {
|
|
401
|
+
'variant_key' => 'control',
|
|
402
|
+
'variant_value' => false
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
stub_flags_request(response)
|
|
406
|
+
|
|
407
|
+
expect(mock_tracker).not_to receive(:call)
|
|
408
|
+
|
|
409
|
+
provider.get_all_variants(test_context)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
it 'returns nil on network error' do
|
|
413
|
+
stub_flags_request_error(Errno::ECONNREFUSED)
|
|
414
|
+
|
|
415
|
+
result = provider.get_all_variants(test_context)
|
|
416
|
+
|
|
417
|
+
expect(result).to be_nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
it 'handles empty response' do
|
|
421
|
+
stub_flags_request(create_success_response({}))
|
|
422
|
+
|
|
423
|
+
result = provider.get_all_variants(test_context)
|
|
424
|
+
|
|
425
|
+
expect(result).to eq({})
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
describe '#track_exposure_event' do
|
|
430
|
+
it 'successfully tracks' do
|
|
431
|
+
variant = Mixpanel::Flags::SelectedVariant.new(
|
|
432
|
+
variant_key: 'treatment',
|
|
433
|
+
variant_value: 'treatment'
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
expect(mock_tracker).to receive(:call).once
|
|
437
|
+
|
|
438
|
+
provider.send(:track_exposure_event, 'test_flag', variant, test_context)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require 'rspec'
|
|
2
|
+
require 'mixpanel-ruby/flags/utils'
|
|
3
|
+
|
|
4
|
+
describe Mixpanel::Flags::Utils do
|
|
5
|
+
describe '.generate_traceparent' do
|
|
6
|
+
it 'should generate traceparent in W3C format' do
|
|
7
|
+
traceparent = Mixpanel::Flags::Utils.generate_traceparent
|
|
8
|
+
|
|
9
|
+
# W3C traceparent format: 00-{32 hex chars}-{16 hex chars}-01
|
|
10
|
+
# https://www.w3.org/TR/trace-context/#traceparent-header
|
|
11
|
+
pattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/
|
|
12
|
+
|
|
13
|
+
expect(traceparent).to match(pattern)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '.normalized_hash' do
|
|
18
|
+
def expect_valid_hash(hash)
|
|
19
|
+
expect(hash).to be_a(Float)
|
|
20
|
+
expect(hash).to be >= 0.0
|
|
21
|
+
expect(hash).to be < 1.0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'should match known test vectors' do
|
|
25
|
+
hash1 = Mixpanel::Flags::Utils.normalized_hash('abc', 'variant')
|
|
26
|
+
expect(hash1).to eq(0.72)
|
|
27
|
+
|
|
28
|
+
hash2 = Mixpanel::Flags::Utils.normalized_hash('def', 'variant')
|
|
29
|
+
expect(hash2).to eq(0.21)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'should produce consistent results' do
|
|
33
|
+
hash1 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt')
|
|
34
|
+
hash2 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt')
|
|
35
|
+
hash3 = Mixpanel::Flags::Utils.normalized_hash('test_key', 'salt')
|
|
36
|
+
|
|
37
|
+
expect(hash1).to eq(hash2)
|
|
38
|
+
expect(hash2).to eq(hash3)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'should produce different hashes when salt is changed' do
|
|
42
|
+
hash1 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'salt1')
|
|
43
|
+
hash2 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'salt2')
|
|
44
|
+
hash3 = Mixpanel::Flags::Utils.normalized_hash('same_key', 'different_salt')
|
|
45
|
+
|
|
46
|
+
expect(hash1).not_to eq(hash2)
|
|
47
|
+
expect(hash1).not_to eq(hash3)
|
|
48
|
+
expect(hash2).not_to eq(hash3)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'should produce different hashes when order is changed' do
|
|
52
|
+
hash1 = Mixpanel::Flags::Utils.normalized_hash('abc', 'salt')
|
|
53
|
+
hash2 = Mixpanel::Flags::Utils.normalized_hash('bac', 'salt')
|
|
54
|
+
hash3 = Mixpanel::Flags::Utils.normalized_hash('cba', 'salt')
|
|
55
|
+
|
|
56
|
+
expect(hash1).not_to eq(hash2)
|
|
57
|
+
expect(hash1).not_to eq(hash3)
|
|
58
|
+
expect(hash2).not_to eq(hash3)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe 'edge cases with empty strings' do
|
|
62
|
+
it 'should return valid hash for empty key' do
|
|
63
|
+
hash = Mixpanel::Flags::Utils.normalized_hash('', 'salt')
|
|
64
|
+
expect_valid_hash(hash)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'should return valid hash for empty salt' do
|
|
68
|
+
hash = Mixpanel::Flags::Utils.normalized_hash('key', '')
|
|
69
|
+
expect_valid_hash(hash)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'should return valid hash for both empty' do
|
|
73
|
+
hash = Mixpanel::Flags::Utils.normalized_hash('', '')
|
|
74
|
+
expect_valid_hash(hash)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'empty strings in different positions should produce different results' do
|
|
78
|
+
hash1 = Mixpanel::Flags::Utils.normalized_hash('', 'salt')
|
|
79
|
+
hash2 = Mixpanel::Flags::Utils.normalized_hash('key', '')
|
|
80
|
+
expect(hash1).not_to eq(hash2)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
describe 'special characters' do
|
|
85
|
+
test_cases = [
|
|
86
|
+
{ key: '🎉', description: 'emoji' },
|
|
87
|
+
{ key: 'beyoncé', description: 'accented characters' },
|
|
88
|
+
{ key: 'key@#$%^&*()', description: 'special symbols' },
|
|
89
|
+
{ key: 'key with spaces', description: 'spaces' }
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
test_cases.each do |test_case|
|
|
93
|
+
it "should return valid hash for #{test_case[:description]}" do
|
|
94
|
+
hash = Mixpanel::Flags::Utils.normalized_hash(test_case[:key], 'salt')
|
|
95
|
+
expect_valid_hash(hash)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'produces different results for different special characters' do
|
|
100
|
+
hashes = test_cases.map { |tc| Mixpanel::Flags::Utils.normalized_hash(tc[:key], 'salt') }
|
|
101
|
+
|
|
102
|
+
hashes.each_with_index do |hash1, i|
|
|
103
|
+
hashes.each_with_index do |hash2, j|
|
|
104
|
+
expect(hash1).not_to eq(hash2) if i != j
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -23,7 +23,7 @@ describe Mixpanel::Groups do
|
|
|
23
23
|
'$token' => 'TEST TOKEN',
|
|
24
24
|
'$group_key' => 'TEST GROUP KEY',
|
|
25
25
|
'$group_id' => 'TEST GROUP ID',
|
|
26
|
-
'$time' => @time_now.
|
|
26
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
27
27
|
'$set' => {
|
|
28
28
|
'$groupname' => 'Mixpanel',
|
|
29
29
|
'$grouprevenue' => 200
|
|
@@ -39,7 +39,7 @@ describe Mixpanel::Groups do
|
|
|
39
39
|
'$token' => 'TEST TOKEN',
|
|
40
40
|
'$group_key' => 'TEST GROUP KEY',
|
|
41
41
|
'$group_id' => 'TEST GROUP ID',
|
|
42
|
-
'$time' => @time_now.
|
|
42
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
43
43
|
'$set' => {
|
|
44
44
|
'created_at' => '2013-01-02T03:04:05'
|
|
45
45
|
}
|
|
@@ -54,7 +54,7 @@ describe Mixpanel::Groups do
|
|
|
54
54
|
'$token' => 'TEST TOKEN',
|
|
55
55
|
'$group_key' => 'TEST GROUP KEY',
|
|
56
56
|
'$group_id' => 'TEST GROUP ID',
|
|
57
|
-
'$time' => @time_now.
|
|
57
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
58
58
|
'$set' => {
|
|
59
59
|
'created_at' => '2013-01-02T02:04:05'
|
|
60
60
|
}
|
|
@@ -70,7 +70,7 @@ describe Mixpanel::Groups do
|
|
|
70
70
|
'$token' => 'TEST TOKEN',
|
|
71
71
|
'$group_key' => 'TEST GROUP KEY',
|
|
72
72
|
'$group_id' => 'TEST GROUP ID',
|
|
73
|
-
'$time' => @time_now.
|
|
73
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
74
74
|
'$set' => {
|
|
75
75
|
'created_at' => '2013-01-02T02:04:05'
|
|
76
76
|
}
|
|
@@ -86,7 +86,7 @@ describe Mixpanel::Groups do
|
|
|
86
86
|
'$token' => 'TEST TOKEN',
|
|
87
87
|
'$group_key' => 'TEST GROUP KEY',
|
|
88
88
|
'$group_id' => 'TEST GROUP ID',
|
|
89
|
-
'$time' => @time_now.
|
|
89
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
90
90
|
'$set_once' => {
|
|
91
91
|
'$groupname' => 'Mixpanel',
|
|
92
92
|
'$grouprevenue' => 200
|
|
@@ -102,7 +102,7 @@ describe Mixpanel::Groups do
|
|
|
102
102
|
'$token' => 'TEST TOKEN',
|
|
103
103
|
'$group_key' => 'TEST GROUP KEY',
|
|
104
104
|
'$group_id' => 'TEST GROUP ID',
|
|
105
|
-
'$time' => @time_now.
|
|
105
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
106
106
|
'$remove' => {
|
|
107
107
|
'Albums' => 'Diamond Dogs'
|
|
108
108
|
}
|
|
@@ -117,7 +117,7 @@ describe Mixpanel::Groups do
|
|
|
117
117
|
'$token' => 'TEST TOKEN',
|
|
118
118
|
'$group_key' => 'TEST GROUP KEY',
|
|
119
119
|
'$group_id' => 'TEST GROUP ID',
|
|
120
|
-
'$time' => @time_now.
|
|
120
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
121
121
|
'$union' => {
|
|
122
122
|
'Albums' => ['Diamond Dogs']
|
|
123
123
|
}
|
|
@@ -130,7 +130,7 @@ describe Mixpanel::Groups do
|
|
|
130
130
|
'$token' => 'TEST TOKEN',
|
|
131
131
|
'$group_key' => 'TEST GROUP KEY',
|
|
132
132
|
'$group_id' => 'TEST GROUP ID',
|
|
133
|
-
'$time' => @time_now.
|
|
133
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
134
134
|
'$unset' => ['Albums']
|
|
135
135
|
}]])
|
|
136
136
|
end
|
|
@@ -141,7 +141,7 @@ describe Mixpanel::Groups do
|
|
|
141
141
|
'$token' => 'TEST TOKEN',
|
|
142
142
|
'$group_key' => 'TEST GROUP KEY',
|
|
143
143
|
'$group_id' => 'TEST GROUP ID',
|
|
144
|
-
'$time' => @time_now.
|
|
144
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
145
145
|
'$unset' => ['Albums', 'Vinyls']
|
|
146
146
|
}]])
|
|
147
147
|
end
|
|
@@ -152,7 +152,7 @@ describe Mixpanel::Groups do
|
|
|
152
152
|
'$token' => 'TEST TOKEN',
|
|
153
153
|
'$group_key' => 'TEST GROUP KEY',
|
|
154
154
|
'$group_id' => 'TEST GROUP ID',
|
|
155
|
-
'$time' => @time_now.
|
|
155
|
+
'$time' => (@time_now.to_f * 1000).to_i,
|
|
156
156
|
'$delete' => ''
|
|
157
157
|
}]])
|
|
158
158
|
end
|
|
@@ -29,7 +29,7 @@ describe Mixpanel::Tracker do
|
|
|
29
29
|
'mp_lib' => 'ruby',
|
|
30
30
|
'$lib_version' => Mixpanel::VERSION,
|
|
31
31
|
'token' => 'TEST TOKEN',
|
|
32
|
-
'time' => @time_now.to_i
|
|
32
|
+
'time' => @time_now.to_i * 1000
|
|
33
33
|
}
|
|
34
34
|
expected_data = {'event' => event, 'properties' => properties.merge(default_properties)}
|
|
35
35
|
|
|
@@ -61,7 +61,7 @@ describe Mixpanel::Tracker do
|
|
|
61
61
|
with { |req| body = req.body }
|
|
62
62
|
|
|
63
63
|
message_urlencoded = body[/^data=(.*?)(?:&|$)/, 1]
|
|
64
|
-
message_json = Base64.strict_decode64(
|
|
64
|
+
message_json = Base64.strict_decode64(CGI.unescape(message_urlencoded))
|
|
65
65
|
message = JSON.load(message_json)
|
|
66
66
|
expect(message).to eq({
|
|
67
67
|
'event' => 'TEST EVENT',
|
|
@@ -71,7 +71,7 @@ describe Mixpanel::Tracker do
|
|
|
71
71
|
'mp_lib' => 'ruby',
|
|
72
72
|
'$lib_version' => Mixpanel::VERSION,
|
|
73
73
|
'token' => 'TEST TOKEN',
|
|
74
|
-
'time' => @time_now.to_i
|
|
74
|
+
'time' => @time_now.to_i * 1000
|
|
75
75
|
}
|
|
76
76
|
})
|
|
77
77
|
end
|
|
@@ -94,7 +94,7 @@ describe Mixpanel::Tracker do
|
|
|
94
94
|
'mp_lib' => 'ruby',
|
|
95
95
|
'$lib_version' => Mixpanel::VERSION,
|
|
96
96
|
'token' => 'TEST TOKEN',
|
|
97
|
-
'time' => @time_now.to_i
|
|
97
|
+
'time' => @time_now.to_i * 1000
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
],
|
|
@@ -106,7 +106,7 @@ describe Mixpanel::Tracker do
|
|
|
106
106
|
'mp_lib' => 'ruby',
|
|
107
107
|
'$lib_version' => Mixpanel::VERSION,
|
|
108
108
|
'token' => 'TEST TOKEN',
|
|
109
|
-
'time' => @time_now.to_i
|
|
109
|
+
'time' => @time_now.to_i * 1000
|
|
110
110
|
}
|
|
111
111
|
},
|
|
112
112
|
'api_key' => 'API_KEY',
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
require 'simplecov'
|
|
2
|
+
require 'simplecov-cobertura'
|
|
3
|
+
|
|
4
|
+
SimpleCov.start do
|
|
5
|
+
add_filter '/spec/'
|
|
6
|
+
add_filter '/demo/'
|
|
7
|
+
|
|
8
|
+
# Generate both HTML and Cobertura XML formats since CodeCov typically requires XML
|
|
9
|
+
formatter SimpleCov::Formatter::MultiFormatter.new([
|
|
10
|
+
SimpleCov::Formatter::HTMLFormatter,
|
|
11
|
+
SimpleCov::Formatter::CoberturaFormatter
|
|
12
|
+
])
|
|
13
|
+
end
|
|
14
|
+
|
|
1
15
|
require 'json'
|
|
2
16
|
require 'webmock/rspec'
|
|
3
17
|
|