mixpanel-ruby 3.2.0 → 3.4.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.
@@ -1,6 +1,6 @@
1
1
  # mixpanel-ruby-openfeature
2
2
 
3
- ##### _May 13, 2026_ - [openfeature/v0.1.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.1.0)
3
+ ##### _July 28, 2026_ - [openfeature/v0.2.0](https://github.com/mixpanel/mixpanel-ruby/releases/tag/openfeature/v0.2.0)
4
4
 
5
5
  [![Gem Version](https://img.shields.io/gem/v/mixpanel-ruby-openfeature.svg)](https://rubygems.org/gems/mixpanel-ruby-openfeature)
6
6
  [![OpenFeature](https://img.shields.io/badge/OpenFeature-compatible-green)](https://openfeature.dev/)
@@ -201,6 +201,34 @@ When you are done using the provider, shut it down to stop any background pollin
201
201
  provider.shutdown
202
202
  ```
203
203
 
204
+ ## Async Exposure Tracking
205
+
206
+ By default, every flag evaluation tracks an exposure event inline — the `/track` HTTP round trip happens on the calling thread before `fetch_*_value` returns. For latency-sensitive code paths, pass an `:exposure_executor` so exposure tracking runs off-thread. The executor is duck-typed — anything that responds to `#post(&block)` works:
207
+
208
+ ```ruby
209
+ # With concurrent-ruby (recommended)
210
+ require 'concurrent'
211
+ exposure_executor = Concurrent::FixedThreadPool.new(1)
212
+
213
+ provider = Mixpanel::OpenFeature::Provider.from_local(
214
+ 'YOUR_PROJECT_TOKEN',
215
+ { exposure_executor: exposure_executor },
216
+ )
217
+
218
+ # Without concurrent-ruby (minimal wrapper)
219
+ class ThreadPerCall
220
+ def post(&block) = Thread.new(&block)
221
+ end
222
+ provider = Mixpanel::OpenFeature::Provider.from_local(
223
+ 'YOUR_PROJECT_TOKEN',
224
+ { exposure_executor: ThreadPerCall.new },
225
+ )
226
+ ```
227
+
228
+ Available on both local and remote flag configs. Defaults to `nil` (inline behavior); existing setups are unaffected.
229
+
230
+ > **Executor lifecycle:** `provider.shutdown` stops the flags provider's own resources (polling) but does NOT shut down a user-supplied executor. Own the executor's lifecycle explicitly — e.g. `at_exit { exposure_executor.shutdown; exposure_executor.wait_for_termination(5) }` for `Concurrent::FixedThreadPool`. Skipping this can leave background threads alive at process exit and delay shutdown.
231
+
204
232
  ## Error Handling
205
233
 
206
234
  The provider uses OpenFeature's standard error codes to indicate issues during flag evaluation:
@@ -69,16 +69,41 @@ module Mixpanel
69
69
 
70
70
  begin
71
71
  result = @flags_provider.get_variant(flag_key, fallback, context, report_exposure: true)
72
- rescue StandardError
73
- return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL)
72
+ rescue StandardError => e
73
+ return error_result(default_value, ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, e.message)
74
74
  end
75
75
 
76
- if result.equal?(fallback)
76
+ # variant_source distinguishes local / remote / fallback. When fallback,
77
+ # fallback_reason carries the specific reason (PHP-aligned constants)
78
+ # so we can map each to the spec-correct OpenFeature response instead
79
+ # of collapsing every fallback to FLAG_NOT_FOUND. SDK-83: BACKEND_ERROR
80
+ # also carries the backend message, forwarded as error_message.
81
+ case result.fallback_reason&.kind
82
+ when :flag_not_found
77
83
  return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
78
84
  value: default_value,
79
85
  error_code: ::OpenFeature::SDK::Provider::ErrorCode::FLAG_NOT_FOUND,
80
86
  reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
81
87
  )
88
+ when :missing_context_key
89
+ return error_result(
90
+ default_value,
91
+ ::OpenFeature::SDK::Provider::ErrorCode::TARGETING_KEY_MISSING,
92
+ result.fallback_reason.message
93
+ )
94
+ when :no_rollout_match
95
+ # Flag exists, user just didn't match any rollout — per the
96
+ # OpenFeature spec this is `reason: DEFAULT` with no error.
97
+ return ::OpenFeature::SDK::Provider::ResolutionDetails.new(
98
+ value: default_value,
99
+ reason: ::OpenFeature::SDK::Provider::Reason::DEFAULT
100
+ )
101
+ when :backend_error
102
+ return error_result(
103
+ default_value,
104
+ ::OpenFeature::SDK::Provider::ErrorCode::GENERAL,
105
+ result.fallback_reason.message
106
+ )
82
107
  end
83
108
 
84
109
  value = result.variant_value
@@ -158,10 +183,11 @@ module Mixpanel
158
183
  end
159
184
  end
160
185
 
161
- def error_result(default_value, error_code)
186
+ def error_result(default_value, error_code, error_message = nil)
162
187
  ::OpenFeature::SDK::Provider::ResolutionDetails.new(
163
188
  value: default_value,
164
189
  error_code: error_code,
190
+ error_message: error_message,
165
191
  reason: ::OpenFeature::SDK::Provider::Reason::ERROR
166
192
  )
167
193
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Mixpanel
4
4
  module OpenFeature
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
7
7
  end
@@ -17,7 +17,7 @@ Gem::Specification.new do |spec|
17
17
  spec.require_paths = ['lib']
18
18
 
19
19
  spec.add_runtime_dependency 'openfeature-sdk', '~> 0.5'
20
- spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.1'
20
+ spec.add_runtime_dependency 'mixpanel-ruby', '~> 3.3'
21
21
 
22
22
  spec.add_development_dependency 'rspec', '~> 3.0'
23
23
  spec.add_development_dependency 'simplecov'
@@ -75,15 +75,25 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
75
75
  def setup_flag(flag_key, value, variant_key: 'variant-key')
76
76
  allow(mock_flags).to receive(:get_variant) do |key, fallback, _ctx, **_kwargs|
77
77
  if key == flag_key
78
- Mixpanel::Flags::SelectedVariant.new(variant_key: variant_key, variant_value: value)
78
+ Mixpanel::Flags::SelectedVariant.new(
79
+ variant_key: variant_key,
80
+ variant_value: value,
81
+ variant_source: Mixpanel::Flags::VariantSource::LOCAL
82
+ )
79
83
  else
80
- fallback
84
+ fallback.as_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
81
85
  end
82
86
  end
83
87
  end
84
88
 
89
+ def setup_fallback(reason)
90
+ allow(mock_flags).to receive(:get_variant) do |_key, fallback, _ctx, **_kwargs|
91
+ fallback.as_fallback(reason)
92
+ end
93
+ end
94
+
85
95
  def setup_flag_not_found
86
- allow(mock_flags).to receive(:get_variant) { |_key, fallback, _ctx, **_kwargs| fallback }
96
+ setup_fallback(Mixpanel::Flags::FallbackReason.flag_not_found)
87
97
  end
88
98
 
89
99
  # --- Metadata ---
@@ -305,6 +315,67 @@ RSpec.describe Mixpanel::OpenFeature::Provider do
305
315
  end
306
316
  end
307
317
 
318
+ # --- NO_ROLLOUT_MATCH (flag exists, no rollout matched) ---
319
+
320
+ describe 'no rollout match' do
321
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.no_rollout_match) }
322
+
323
+ it 'returns DEFAULT reason without an error code' do
324
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: true)
325
+ expect(result.value).to be true
326
+ expect(result.reason).to eq('DEFAULT')
327
+ expect(result.error_code).to be_nil
328
+ end
329
+
330
+ it 'works for strings' do
331
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
332
+ expect(result.value).to eq('default')
333
+ expect(result.reason).to eq('DEFAULT')
334
+ expect(result.error_code).to be_nil
335
+ end
336
+ end
337
+
338
+ # --- MISSING_CONTEXT_KEY ---
339
+
340
+ describe 'missing context key' do
341
+ before { setup_fallback(Mixpanel::Flags::FallbackReason.missing_context_key('distinct_id')) }
342
+
343
+ it 'returns TARGETING_KEY_MISSING for boolean' do
344
+ result = provider.fetch_boolean_value(flag_key: 'flag', default_value: false)
345
+ expect(result.value).to be false
346
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
347
+ expect(result.reason).to eq('ERROR')
348
+ end
349
+
350
+ it 'returns TARGETING_KEY_MISSING for string and forwards the missing key as error_message' do
351
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
352
+ expect(result.value).to eq('default')
353
+ expect(result.error_code).to eq('TARGETING_KEY_MISSING')
354
+ expect(result.error_message).to eq('distinct_id')
355
+ expect(result.reason).to eq('ERROR')
356
+ end
357
+ end
358
+
359
+ # --- BACKEND_ERROR (SDK-83: forwards the backend's response message) ---
360
+
361
+ describe 'backend error' do
362
+ before do
363
+ setup_fallback(
364
+ Mixpanel::Flags::FallbackReason.backend_error(
365
+ 'HTTP 400: distinct_id must be provided in evalContext as a string'
366
+ )
367
+ )
368
+ end
369
+
370
+ it 'maps to GENERAL and forwards the backend message as error_message' do
371
+ result = provider.fetch_string_value(flag_key: 'flag', default_value: 'default')
372
+ expect(result.value).to eq('default')
373
+ expect(result.error_code).to eq('GENERAL')
374
+ expect(result.reason).to eq('ERROR')
375
+ expect(result.error_message).to include('distinct_id must be provided')
376
+ end
377
+ end
378
+
308
379
  # --- PROVIDER_NOT_READY ---
309
380
 
310
381
  describe 'provider not ready' do
@@ -0,0 +1,100 @@
1
+ [
2
+ "A list of golden vectors for custom operators, to ensure logic parity across platforms",
3
+
4
+ "# Ordering and the six symbols",
5
+ ["2026-07-15T00:00:00Z", "<", 1784160000000, true],
6
+ ["2026-07-16T00:00:00Z", "<", 1784160000000, false],
7
+ ["2026-07-16T00:00:00Z", "===", 1784160000000, true],
8
+ ["2026-07-17T00:00:00Z", "!==", 1784160000000, true],
9
+ ["2026-07-16T00:00:00Z", ">=", 1784160000000, true],
10
+ ["2026-07-17T00:00:00Z", ">", 1784160000000, true],
11
+ ["2026-07-15T00:00:00Z", ">", 1784160000000, false],
12
+ ["2026-07-16T00:00:00Z", "<=", 1784160000000, true],
13
+ ["2026-07-17T00:00:00Z", "<=", 1784160000000, false],
14
+ ["2026-07-17T00:00:00Z", "===", 1784160000000, false],
15
+ ["2026-07-16T00:00:00Z", "!==", 1784160000000, false],
16
+ ["2026-07-15T00:00:00Z", ">=", 1784160000000, false],
17
+
18
+ "# Leap day",
19
+ ["2024-02-29T00:00:00Z", "===", 1709164800000, true],
20
+
21
+ "# Time-zone offsets change the instant",
22
+ ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true],
23
+ ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true],
24
+ ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true],
25
+ ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true],
26
+ ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true],
27
+ ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true],
28
+
29
+ "# Sub-second precision is dropped",
30
+ ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true],
31
+ ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true],
32
+ ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true],
33
+ ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true],
34
+ ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true],
35
+ ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true],
36
+ ["2026-07-16T23:59:59Z", "===", 1784246399000, true],
37
+ ["2026-07-16T23:59:59Z", "<=", 1784246399000, true],
38
+ ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true],
39
+ ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true],
40
+
41
+ "# Trimming and lowercasing",
42
+ ["2026-07-16t00:00:00.500z", "===", 1784160000000, true],
43
+ ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true],
44
+ [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true],
45
+ ["2026-07-16t00:00:00z", "===", 1784160000000, true],
46
+
47
+ "# Wrong shapes, checked under both symbols",
48
+ ["2026-7-16T00:00:00Z", "===", 1784160000000, false],
49
+ ["2026-7-16T00:00:00Z", "!==", 1784160000000, false],
50
+ ["2026-07-16 00:00:00Z", "===", 1784160000000, false],
51
+ ["2026-07-16 00:00:00Z", "!==", 1784160000000, false],
52
+ ["2026-07-16T00:00:00", "===", 1784160000000, false],
53
+ ["2026-07-16T00:00:00", "!==", 1784160000000, false],
54
+ ["2026-07-16T00:00:00.Z", "===", 1784160000000, false],
55
+ ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false],
56
+ ["2026-07-16T00:00:00+0200", "===", 1784160000000, false],
57
+ ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false],
58
+ ["2026-07-16T00:00:00+02", "===", 1784160000000, false],
59
+ ["2026-07-16T00:00:00+02", "!==", 1784160000000, false],
60
+ ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false],
61
+ ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false],
62
+ ["2026-07-16", "===", 1784160000000, false],
63
+ ["2026-07-16", "!==", 1784160000000, false],
64
+ ["20260716T000000Z", "===", 1784160000000, false],
65
+ ["20260716T000000Z", "!==", 1784160000000, false],
66
+ ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false],
67
+ ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false],
68
+ ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false],
69
+ ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false],
70
+
71
+ "# Missing or wrong-typed values",
72
+ [1784160000000, "===", 1784160000000, false],
73
+ ["2026-07-16T00:00:00Z", "===", 1e19, false],
74
+ ["2026-07-16T00:00:00Z", ">", 1e19, false],
75
+ ["2026-07-16T00:00:00Z", "<", 1e19, false],
76
+ ["2026-07-16", "===", 1784160000000, false],
77
+ ["2026-07-16T00:00:00", "===", 1784160000000, false],
78
+ ["yesterday", "===", 1784160000000, false],
79
+ [null, "===", 1784160000000, false],
80
+
81
+ "# Targets before 1970",
82
+ ["1969-12-31T23:59:59Z", "===", -1000, true],
83
+ ["1969-12-31T23:59:59Z", "!==", -1000, false],
84
+ ["1969-12-31T23:59:59Z", ">=", -1000, true],
85
+ ["1969-12-31T23:59:58Z", "<", -1000, true],
86
+ ["1969-12-31T23:59:59Z", ">", -2000, true],
87
+ ["1969-12-31T23:59:58.500Z", "===", -2000, true],
88
+ ["1969-12-31T23:59:58.500Z", "!==", -1000, true],
89
+
90
+ "# Impossible dates and out-of-range fields",
91
+ ["2026-02-30T00:00:00Z", "===", 1784160000000, false],
92
+ ["2026-02-30T00:00:00Z", "!==", 1784160000000, false],
93
+ ["2026-02-29T00:00:00Z", "!==", 1784160000000, false],
94
+ ["2025-02-29T00:00:00Z", "!==", 1784160000000, false],
95
+ ["2026-04-31T00:00:00Z", "!==", 1784160000000, false],
96
+ ["2026-06-31T00:00:00Z", "!==", 1784160000000, false],
97
+ ["2026-07-16T24:00:00Z", "!==", 1784160000000, false],
98
+ ["2026-13-01T00:00:00Z", "!==", 1784160000000, false],
99
+ ["2026-01-32T00:00:00Z", "!==", 1784160000000, false]
100
+ ]
@@ -0,0 +1,161 @@
1
+ [
2
+ "A list of golden vectors for custom operators, to ensure logic parity across platforms",
3
+
4
+ "# Ordering and the six symbols",
5
+ ["1.2.3", "===", "1.2.3", true],
6
+ ["1.2.4", "===", "1.2.3", false],
7
+ ["1.2.4", "!==", "1.2.3", true],
8
+ ["1.2.2", "<", "1.2.3", true],
9
+ ["1.2.3", "<", "1.2.3", false],
10
+ ["1.2.3", "<=", "1.2.3", true],
11
+ ["1.3.0", ">", "1.2.3", true],
12
+ ["1.2.3", ">=", "1.2.3", true],
13
+ ["1.10.0", ">", "1.9.0", true],
14
+ ["10.0.0", ">", "9.0.0", true],
15
+ ["1.0.10", ">", "1.0.9", true],
16
+ ["2.0.0", ">", "1.9.9", true],
17
+ ["1.0.0-alpha", "<", "1.0.0", true],
18
+ ["v1.2.3", "===", "1.2.3", true],
19
+ ["1.2.0", "===", "1.2", true],
20
+ [" 1.2.3 ", "===", "1.2.3", true],
21
+ ["1.2.3", "!==", "1.2.3", false],
22
+ ["1.2.4", "<=", "1.2.3", false],
23
+ ["1.2.2", ">", "1.2.3", false],
24
+ ["1.2.2", ">=", "1.2.3", false],
25
+
26
+ "# Pre-release ordering",
27
+ ["1.0.0-alpha", "<", "1.0.0-beta", true],
28
+ ["1.0.0-beta", "<", "1.0.0-rc1", true],
29
+ ["1.0.0-rc1", "<", "1.0.0-rc2", true],
30
+ ["1.0.0-alpha", "<", "1.0.0-alpha.1", true],
31
+ ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true],
32
+ ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true],
33
+ ["1.0.0-beta.2", "<", "1.0.0-beta.11", true],
34
+ ["1.0.0-a.1", "<", "1.0.0-b.1", true],
35
+ ["1.0.0-a.1", "<", "1.0.0-a.2", true],
36
+ ["1.0.0-rc1", "===", "1.0.0-rc1", true],
37
+ ["1.0.0-rc1", ">", "1.0.0-rc.1", true],
38
+ ["2.0.0-alpha", ">", "1.9.9", true],
39
+
40
+ "# A pre-release and a plain release, compared directly",
41
+ ["1.0.0", ">", "1.0.0-alpha", true],
42
+ ["1.0.0", ">=", "1.0.0-rc1", true],
43
+ ["1.0.0", "!==", "1.0.0-alpha", true],
44
+ ["1.0.0-alpha", "!==", "1.0.0", true],
45
+ ["1.0.0-alpha", "<=", "1.0.0", true],
46
+ ["1.0.0-alpha", ">", "0.9.9", true],
47
+ ["1.0.0-rc1", "<", "1.0.1", true],
48
+
49
+ "# How pre-release identifiers compare, SemVer 2.0.0 item 11",
50
+ ["1.0.0-2", "<", "1.0.0-10", true],
51
+ ["1.0.0-1", "<", "1.0.0-alpha", true],
52
+ ["1.0.0-alpha", "<", "1.0.0-alpha-1", true],
53
+ ["1.0.0-beta.11", "<", "1.0.0-rc.1", true],
54
+ ["1.0.0-rc.1", "<", "1.0.0", true],
55
+ ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true],
56
+ ["1.0.0-beta", ">", "1.0.0-alpha.1", true],
57
+
58
+ "# Build metadata is ignored",
59
+ ["1.0.0+build1", "===", "1.0.0+build2", true],
60
+ ["1.0.0-alpha+build", "===", "1.0.0-alpha", true],
61
+ ["1.2.3+build.1-2", "===", "1.2.3", true],
62
+ ["1.0.0+build1", "!==", "1.0.0+build2", false],
63
+ ["1.0.0+build1", "<", "1.0.0+build2", false],
64
+ ["1.0.0+build1", ">", "1.0.0+build2", false],
65
+ ["1.0.0+build1", "<=", "1.0.0+build2", true],
66
+ ["1.0.0+build1", ">=", "1.0.0+build2", true],
67
+ ["1.0.0+build9", "<", "1.0.1+build1", true],
68
+ ["1.0.1+build1", ">", "1.0.0+build9", true],
69
+
70
+ "# Partial versions",
71
+ ["1.2-alpha", "===", "1.2.0-alpha", true],
72
+ ["1.2-alpha", "<", "1.3.1", true],
73
+ ["1.2-alpha", "<", "1.2.0", true],
74
+ ["1-rc1", "<", "1.0.0", true],
75
+ ["1.2+build", "===", "1.2.0", true],
76
+
77
+ "# Zero versions",
78
+ ["0.0.0", "===", "0.0.0", true],
79
+ ["0.0.0", "<", "0.0.1", true],
80
+ ["0", "===", "0.0.0", true],
81
+
82
+ "# A version ending in a bare hyphen is rejected",
83
+ ["1.0.0-", "===", "1.0.0", false],
84
+ ["1.0.0-", "!==", "1.0.0", false],
85
+ ["1.2-", "===", "1.2.0", false],
86
+ ["1.2-", "!==", "1.2.0", false],
87
+
88
+ "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier",
89
+ ["1.0.0-alpha-", "<", "1.0.0", true],
90
+
91
+ "# Leading zeros are rejected",
92
+ ["01.2.3", "===", "1.2.3", false],
93
+ ["01.2.3", "!==", "1.2.3", false],
94
+ ["1.02.3", "===", "1.2.3", false],
95
+ ["1.02.3", "!==", "1.2.3", false],
96
+ ["1.2.03", "===", "1.2.3", false],
97
+ ["1.2.03", "!==", "1.2.3", false],
98
+ ["01.02.03", "===", "1.2.3", false],
99
+ ["01.02.03", "!==", "1.2.3", false],
100
+
101
+ "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9",
102
+ ["1.2.3-01", "===", "1.2.3", false],
103
+ ["1.2.3-01", "!==", "1.2.3", false],
104
+ ["1.2.3-rc.01", "===", "1.2.3", false],
105
+ ["1.2.3-rc.01", "!==", "1.2.3", false],
106
+
107
+ "# A lone zero is a legal pre-release identifier",
108
+ ["1.2.3-0", "<", "1.2.3", true],
109
+
110
+ "# Digits inside a word are still valid",
111
+ ["1.2.3-rc01", "<", "1.2.3", true],
112
+
113
+ "# A leading v is accepted, in either case",
114
+ ["V1.2.3", "===", "1.2.3", true],
115
+ ["v1.0.0-alpha", "<", "1.0.0", true],
116
+ ["v1.2.4", "!==", "1.2.3", true],
117
+ ["v1.2.3", "<=", "1.2.3", true],
118
+ ["v1.2.4", ">", "1.2.3", true],
119
+ ["v1.2.3", ">=", "1.2.3", true],
120
+
121
+ "# Missing or wrong-typed values",
122
+ ["not-a-version", "===", "1.2.3", false],
123
+ [123, "===", "1.2.3", false],
124
+ [null, "===", "1.2.3", false],
125
+
126
+ "# Malformed versions, checked under both symbols",
127
+ ["", "===", "1.2.3", false],
128
+ ["", "!==", "1.2.3", false],
129
+ ["v", "===", "1.2.3", false],
130
+ ["v", "!==", "1.2.3", false],
131
+ ["-1.2.3", "===", "1.2.3", false],
132
+ ["-1.2.3", "!==", "1.2.3", false],
133
+ ["1.", "===", "1.2.3", false],
134
+ ["1.", "!==", "1.2.3", false],
135
+ ["1.2.3.", "===", "1.2.3", false],
136
+ ["1.2.3.", "!==", "1.2.3", false],
137
+ ["1..2", "===", "1.2.3", false],
138
+ ["1..2", "!==", "1.2.3", false],
139
+ ["1.2.3.4", "===", "1.2.3", false],
140
+ ["1.2.3.4", "!==", "1.2.3", false],
141
+ ["^1.2.3", "===", "1.2.3", false],
142
+ ["^1.2.3", "!==", "1.2.3", false],
143
+ ["abc1.2.3", "===", "1.2.3", false],
144
+ ["abc1.2.3", "!==", "1.2.3", false],
145
+ ["1.2.3+", "===", "1.2.3", false],
146
+ ["1.2.3+", "!==", "1.2.3", false],
147
+ ["1.2.3-alpha..1", "===", "1.2.3", false],
148
+ ["1.2.3-alpha..1", "!==", "1.2.3", false],
149
+ ["1.2.3-.", "===", "1.2.3", false],
150
+ ["1.2.3-.", "!==", "1.2.3", false],
151
+ ["1.2.3-ALPHA_BETA", "===", "1.2.3", false],
152
+ ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false],
153
+ ["vv1.2.3", "===", "1.2.3", false],
154
+ ["vv1.2.3", "!==", "1.2.3", false],
155
+
156
+ "# Operand length cap",
157
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false],
158
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false],
159
+ ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false],
160
+ ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true]
161
+ ]
@@ -0,0 +1,74 @@
1
+ require 'json'
2
+ require 'rspec'
3
+ require 'json_logic'
4
+ require 'mixpanel-ruby/flags/custom_operators'
5
+
6
+ # The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and its
7
+ # README live in the analytics monorepo. Cases run through JsonLogic.apply so that operator
8
+ # registration is covered alongside the comparison itself.
9
+ #
10
+ # Defined at the top level so the case tables can be built while the describe blocks are collected.
11
+ FIXTURES = File.expand_path('../../fixtures', __dir__)
12
+
13
+ # The property key the vectors are evaluated against. It is plumbing the spec supplies, so any name
14
+ # works as long as the rule and the data agree on it.
15
+ VECTOR_KEY = 'value'.freeze
16
+
17
+ def rule_for(operator, symbol, target)
18
+ { "#{operator}_compare" => [{ 'var' => VECTOR_KEY }, symbol, target] }
19
+ end
20
+
21
+ # Build the event the rule reads from, omitting the key entirely for an unset property.
22
+ def data_for(subject)
23
+ subject.nil? ? {} : { VECTOR_KEY => subject }
24
+ end
25
+
26
+ # Read a golden-vector file. String entries are headings, array entries are cases.
27
+ def load_vectors(operator)
28
+ entries = JSON.parse(File.read(File.join(FIXTURES, "#{operator}_compare_tests.json")))
29
+
30
+ section = ''
31
+ cases = []
32
+ entries.each_with_index do |entry, index|
33
+ if entry.is_a?(String)
34
+ section = entry
35
+ next
36
+ end
37
+ subject, symbol, target, want = entry
38
+ name = "#{index} #{section}: #{subject.to_json} #{symbol} #{target.to_json}"
39
+ cases << [name, rule_for(operator, symbol, target), data_for(subject), want]
40
+ end
41
+ cases
42
+ end
43
+
44
+ SEMVER_CASES = load_vectors('semver')
45
+ DATETIME_CASES = load_vectors('datetime')
46
+
47
+ describe Mixpanel::Flags::CustomOperators do
48
+ def apply(rule, data)
49
+ JsonLogic.apply(rule, data)
50
+ end
51
+
52
+ describe 'semver_compare' do
53
+ SEMVER_CASES.each do |name, rule, data, want|
54
+ it name do
55
+ expect(apply(rule, data)).to eq(want)
56
+ end
57
+ end
58
+ end
59
+
60
+ describe 'datetime_compare' do
61
+ DATETIME_CASES.each do |name, rule, data, want|
62
+ it name do
63
+ expect(apply(rule, data)).to eq(want)
64
+ end
65
+ end
66
+ end
67
+
68
+ # An unset property must produce an event with no key at all, rather than a key holding a nil.
69
+ # Both spellings fail closed, so the vectors alone cannot tell them apart.
70
+ it 'omits the property for an unset subject' do
71
+ expect(data_for(nil)).to eq({})
72
+ expect(data_for('1.2.3')).to eq({ VECTOR_KEY => '1.2.3' })
73
+ end
74
+ end