diff --git a/lib/mixpanel-ruby/flags/custom_operators.rb b/lib/mixpanel-ruby/flags/custom_operators.rb new file mode 100644 index 0000000..63a19d8 --- /dev/null +++ b/lib/mixpanel-ruby/flags/custom_operators.rb @@ -0,0 +1,207 @@ +require 'date' +require 'time' +require 'json_logic' + +module Mixpanel + module Flags + module CustomOperators + # Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation + # differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid + # as part of the Semver 2.0.0 spec. See https://semver.org/ + SEMVER_STRICT = /\A(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?\z/ + + # Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the + # calendar can be validated separately; the pattern only constrains their shape. + RFC3339_STRICT = /\A(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})\z/ + + # SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this. + SEMVER_PARTS = 3 + + # Longest operand the semver regex is allowed to see. A real version never approaches this; the + # bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the + # regex regardless of how the engine schedules backtracking. + MAX_SEMVER_LENGTH = 256 + + # Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range. + MAX_EPOCH_MS = 2**63 + + module_function + + # Implements a custom operation for semantic versioning comparison that conforms to the + # semver 2.0.0 standard. Prior to comparison, any leading version prefix is stripped. + def semver_compare(values) + unpacked = operands(values) + return false unless unpacked + + actual, symbol, target = unpacked + return false unless actual.is_a?(String) && target.is_a?(String) + return false if actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH + + actual_version = normalize_semver(actual) + target_version = normalize_semver(target) + return false unless actual_version.match?(SEMVER_STRICT) && target_version.match?(SEMVER_STRICT) + + cmp = compare_semver(actual_version, target_version) + comparator_matches?(cmp, symbol) + end + + # Strip optional build metadata and separate the core version from pre-release identifiers + def split_semver(version) + plus = version.index('+') + version = version[0, plus] if plus + dash = version.index('-') + return [version.split('.'), []] unless dash + + [version[0, dash].split('.'), version[(dash + 1)..-1].split('.')] + end + + def numeric_identifier?(identifier) + identifier.match?(/\A[0-9]+\z/) + end + + # Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number. + # Comparing them as digits rather than parsing to a fixed-width integer keeps versions that + # overflow a 64-bit integer ordered correctly. + def compare_numeric(a, b) + return a.length <=> b.length unless a.length == b.length + + a <=> b + end + + # SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an + # alphanumeric one, and anything else compares by ASCII order. + def compare_prerelease_identifier(a, b) + a_numeric = numeric_identifier?(a) + b_numeric = numeric_identifier?(b) + return compare_numeric(a, b) if a_numeric && b_numeric + return -1 if a_numeric + return 1 if b_numeric + + a <=> b + end + + # Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched + # against the official regex, so the core holds exactly three numeric identifiers and every + # prerelease field is well-formed; the split needs no error path. + def compare_semver(actual, target) + actual_core, actual_prerelease = split_semver(actual) + target_core, target_prerelease = split_semver(target) + + actual_core.each_with_index do |part, index| + result = compare_numeric(part, target_core[index]) + return result unless result.zero? + end + + # A prerelease ranks below the release it belongs to (section 11.3). + return 0 if actual_prerelease.empty? && target_prerelease.empty? + return 1 if actual_prerelease.empty? + return -1 if target_prerelease.empty? + + [actual_prerelease.length, target_prerelease.length].min.times do |index| + result = compare_prerelease_identifier(actual_prerelease[index], target_prerelease[index]) + return result unless result.zero? + end + # Every field so far is equal, so the longer list wins (section 11.4.4). + actual_prerelease.length <=> target_prerelease.length + end + + # Implements a custom operation for datetime comparison. The target value stored on the + # feature flag is the millisecond epoch, whereas the actual value provided at evaluation + # time must be RFC-3339 formatted. + def datetime_compare(values) + unpacked = operands(values) + return false unless unpacked + + actual, symbol, target = unpacked + actual_sec = convert_rfc3339_to_unix_seconds(actual) + target_sec = convert_unix_milliseconds_to_seconds(target) + return false unless actual_sec && target_sec + + cmp = actual_sec - target_sec + comparator_matches?(cmp, symbol) + end + + def operands(values) + return nil unless values.length == 3 + + actual, symbol, target = values + return nil unless symbol.is_a?(String) + + [actual, symbol, target] + end + + def comparator_matches?(cmp, symbol) + case symbol + when '===' then cmp.zero? + when '!==' then !cmp.zero? + when '<' then cmp < 0 + when '<=' then cmp <= 0 + when '>' then cmp > 0 + when '>=' then cmp >= 0 + else false + end + end + + def normalize_semver(str) + stripped = str.strip + stripped = stripped[1..] if stripped =~ /\Av/i + + suffix_start = stripped.length + ['-', '+'].each do |separator| + index = stripped.index(separator) + suffix_start = index if index && index < suffix_start + end + + core = stripped[0, suffix_start] + suffix = stripped[suffix_start..] || '' + + # split(-1) keeps trailing empty fields, so "1." and "1.2.3." stay malformed instead of + # silently padding to a valid version. Returning the input unchanged lets the validator reject it. + segments = core.split('.', -1) + return stripped unless segments.length.between?(1, SEMVER_PARTS) && segments.all? { |seg| seg.match?(/\A\d+\z/) } + + segments += ['0'] * (SEMVER_PARTS - segments.length) + segments.join('.') + suffix + end + + # The pattern constrains each field to two digits, which still admits a date that cannot exist, + # such as 2026-02-30 or 29 February in a common year. Time.iso8601 rolls those forward into a + # real instant instead of raising, and hour 24 likewise becomes the following midnight, so the + # calendar is checked here. RFC 3339 section 5.6 allows hours 00 through 23. + def real_calendar_date?(year, month, day, hour) + hour <= 23 && Date.valid_date?(year, month, day) + end + + def convert_rfc3339_to_unix_seconds(value) + return nil unless value.is_a?(String) + + normalized = value.strip.upcase + fields = RFC3339_STRICT.match(normalized) + return nil unless fields + return nil unless real_calendar_date?(fields[1].to_i, fields[2].to_i, fields[3].to_i, fields[4].to_i) + + parsed = Time.iso8601(normalized) + parsed.to_i + rescue ArgumentError + nil + end + + def convert_unix_milliseconds_to_seconds(value) + return nil unless value.is_a?(Numeric) + # A value int64 cannot represent is not a real timestamp; treating one as a bound would let a + # nonsense target define a rollout window. NaN fails this comparison too. + return nil unless value.abs < MAX_EPOCH_MS + + value.to_i.fdiv(1000).truncate + end + end + end +end + +JsonLogic.add_operation('semver_compare') do |values, _data| + Mixpanel::Flags::CustomOperators.semver_compare(values) +end + +JsonLogic.add_operation('datetime_compare') do |values, _data| + Mixpanel::Flags::CustomOperators.datetime_compare(values) +end diff --git a/lib/mixpanel-ruby/flags/local_flags_provider.rb b/lib/mixpanel-ruby/flags/local_flags_provider.rb index c0189c7..4adf319 100644 --- a/lib/mixpanel-ruby/flags/local_flags_provider.rb +++ b/lib/mixpanel-ruby/flags/local_flags_provider.rb @@ -1,6 +1,7 @@ require 'thread' require 'json_logic' require 'mixpanel-ruby/flags/flags_provider' +require 'mixpanel-ruby/flags/custom_operators' module Mixpanel module Flags @@ -377,7 +378,10 @@ def is_runtime_evaluation_satisfied?(rollout, context) begin rule = lowercase_only_leaf_nodes(runtime_rule) result = JsonLogic.apply(rule, parameters) - !!result + # A well-formed runtime rule evaluates to a boolean. Anything else — + # notably an unrecognized operator, which the engine echoes back as a + # (truthy) hash rather than raising — fails closed. + result == true rescue StandardError => e @error_handler.handle(e) if @error_handler false diff --git a/mixpanel-ruby.gemspec b/mixpanel-ruby.gemspec index a04e617..630cad7 100644 --- a/mixpanel-ruby.gemspec +++ b/mixpanel-ruby.gemspec @@ -15,7 +15,7 @@ spec = Gem::Specification.new do |spec| spec.required_ruby_version = '>= 3.0.0' spec.add_runtime_dependency 'mutex_m' spec.add_runtime_dependency "base64" - spec.add_runtime_dependency 'json-logic-rb', '~> 0.1.5' + spec.add_runtime_dependency 'json-logic-rb', '~> 0.2' spec.add_development_dependency 'activesupport', '~> 4.0' spec.add_development_dependency 'rake', '~> 13' diff --git a/spec/fixtures/datetime_compare_tests.json b/spec/fixtures/datetime_compare_tests.json new file mode 100644 index 0000000..5e8633d --- /dev/null +++ b/spec/fixtures/datetime_compare_tests.json @@ -0,0 +1,100 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["2026-07-15T00:00:00Z", "<", 1784160000000, true], + ["2026-07-16T00:00:00Z", "<", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1784160000000, true], + ["2026-07-17T00:00:00Z", "!==", 1784160000000, true], + ["2026-07-16T00:00:00Z", ">=", 1784160000000, true], + ["2026-07-17T00:00:00Z", ">", 1784160000000, true], + ["2026-07-15T00:00:00Z", ">", 1784160000000, false], + ["2026-07-16T00:00:00Z", "<=", 1784160000000, true], + ["2026-07-17T00:00:00Z", "<=", 1784160000000, false], + ["2026-07-17T00:00:00Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-15T00:00:00Z", ">=", 1784160000000, false], + + "# Leap day", + ["2024-02-29T00:00:00Z", "===", 1709164800000, true], + + "# Time-zone offsets change the instant", + ["2026-07-16T00:00:00+05:30", "===", 1784140200000, true], + ["2026-07-16T02:00:00+02:00", "===", 1784160000000, true], + ["2026-07-16T00:00:00+05:30", "<", 1784160000000, true], + ["2026-07-16T00:00:00-08:00", "===", 1784188800000, true], + ["2026-07-16T00:00:00-08:00", ">", 1784160000000, true], + ["2026-07-16T00:00:00+00:00", "===", 1784160000000, true], + + "# Sub-second precision is dropped", + ["2026-07-16T00:00:00.5Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.123456Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.999999999Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.0Z", "===", 1784160000000, true], + ["2026-07-16T00:00:00.500Z", ">=", 1784160000000, true], + ["2026-07-16T23:59:59Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59Z", "<=", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "===", 1784246399000, true], + ["2026-07-16T23:59:59.999Z", "<=", 1784246399000, true], + + "# Trimming and lowercasing", + ["2026-07-16t00:00:00.500z", "===", 1784160000000, true], + ["2026-07-16t02:00:00+02:00", "===", 1784160000000, true], + [" 2026-07-16T00:00:00Z ", "===", 1784160000000, true], + ["2026-07-16t00:00:00z", "===", 1784160000000, true], + + "# Wrong shapes, checked under both symbols", + ["2026-7-16T00:00:00Z", "===", 1784160000000, false], + ["2026-7-16T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16 00:00:00Z", "===", 1784160000000, false], + ["2026-07-16 00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00.Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "===", 1784160000000, false], + ["2026-07-16T00:00:00+0200", "!==", 1784160000000, false], + ["2026-07-16T00:00:00+02", "===", 1784160000000, false], + ["2026-07-16T00:00:00+02", "!==", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "===", 1784160000000, false], + ["2026-07-16T00:00:00Zextra", "!==", 1784160000000, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16", "!==", 1784160000000, false], + ["20260716T000000Z", "===", 1784160000000, false], + ["20260716T000000Z", "!==", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "===", 1784160000000, false], + ["2026-07-16T00:00:00z00:00", "!==", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "===", 1784160000000, false], + ["2026-07-16T00:00:00,5Z", "!==", 1784160000000, false], + + "# Missing or wrong-typed values", + [1784160000000, "===", 1784160000000, false], + ["2026-07-16T00:00:00Z", "===", 1e19, false], + ["2026-07-16T00:00:00Z", ">", 1e19, false], + ["2026-07-16T00:00:00Z", "<", 1e19, false], + ["2026-07-16", "===", 1784160000000, false], + ["2026-07-16T00:00:00", "===", 1784160000000, false], + ["yesterday", "===", 1784160000000, false], + [null, "===", 1784160000000, false], + + "# Targets before 1970", + ["1969-12-31T23:59:59Z", "===", -1000, true], + ["1969-12-31T23:59:59Z", "!==", -1000, false], + ["1969-12-31T23:59:59Z", ">=", -1000, true], + ["1969-12-31T23:59:58Z", "<", -1000, true], + ["1969-12-31T23:59:59Z", ">", -2000, true], + ["1969-12-31T23:59:58.500Z", "===", -2000, true], + ["1969-12-31T23:59:58.500Z", "!==", -1000, true], + + "# Impossible dates and out-of-range fields", + ["2026-02-30T00:00:00Z", "===", 1784160000000, false], + ["2026-02-30T00:00:00Z", "!==", 1784160000000, false], + ["2026-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2025-02-29T00:00:00Z", "!==", 1784160000000, false], + ["2026-04-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-06-31T00:00:00Z", "!==", 1784160000000, false], + ["2026-07-16T24:00:00Z", "!==", 1784160000000, false], + ["2026-13-01T00:00:00Z", "!==", 1784160000000, false], + ["2026-01-32T00:00:00Z", "!==", 1784160000000, false] +] diff --git a/spec/fixtures/semver_compare_tests.json b/spec/fixtures/semver_compare_tests.json new file mode 100644 index 0000000..42c6c75 --- /dev/null +++ b/spec/fixtures/semver_compare_tests.json @@ -0,0 +1,161 @@ +[ + "A list of golden vectors for custom operators, to ensure logic parity across platforms", + + "# Ordering and the six symbols", + ["1.2.3", "===", "1.2.3", true], + ["1.2.4", "===", "1.2.3", false], + ["1.2.4", "!==", "1.2.3", true], + ["1.2.2", "<", "1.2.3", true], + ["1.2.3", "<", "1.2.3", false], + ["1.2.3", "<=", "1.2.3", true], + ["1.3.0", ">", "1.2.3", true], + ["1.2.3", ">=", "1.2.3", true], + ["1.10.0", ">", "1.9.0", true], + ["10.0.0", ">", "9.0.0", true], + ["1.0.10", ">", "1.0.9", true], + ["2.0.0", ">", "1.9.9", true], + ["1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.3", "===", "1.2.3", true], + ["1.2.0", "===", "1.2", true], + [" 1.2.3 ", "===", "1.2.3", true], + ["1.2.3", "!==", "1.2.3", false], + ["1.2.4", "<=", "1.2.3", false], + ["1.2.2", ">", "1.2.3", false], + ["1.2.2", ">=", "1.2.3", false], + + "# Pre-release ordering", + ["1.0.0-alpha", "<", "1.0.0-beta", true], + ["1.0.0-beta", "<", "1.0.0-rc1", true], + ["1.0.0-rc1", "<", "1.0.0-rc2", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.1", true], + ["1.0.0-alpha.1", "<", "1.0.0-alpha.beta", true], + ["1.0.0-alpha", "<", "1.0.0-alpha.beta", true], + ["1.0.0-beta.2", "<", "1.0.0-beta.11", true], + ["1.0.0-a.1", "<", "1.0.0-b.1", true], + ["1.0.0-a.1", "<", "1.0.0-a.2", true], + ["1.0.0-rc1", "===", "1.0.0-rc1", true], + ["1.0.0-rc1", ">", "1.0.0-rc.1", true], + ["2.0.0-alpha", ">", "1.9.9", true], + + "# A pre-release and a plain release, compared directly", + ["1.0.0", ">", "1.0.0-alpha", true], + ["1.0.0", ">=", "1.0.0-rc1", true], + ["1.0.0", "!==", "1.0.0-alpha", true], + ["1.0.0-alpha", "!==", "1.0.0", true], + ["1.0.0-alpha", "<=", "1.0.0", true], + ["1.0.0-alpha", ">", "0.9.9", true], + ["1.0.0-rc1", "<", "1.0.1", true], + + "# How pre-release identifiers compare, SemVer 2.0.0 item 11", + ["1.0.0-2", "<", "1.0.0-10", true], + ["1.0.0-1", "<", "1.0.0-alpha", true], + ["1.0.0-alpha", "<", "1.0.0-alpha-1", true], + ["1.0.0-beta.11", "<", "1.0.0-rc.1", true], + ["1.0.0-rc.1", "<", "1.0.0", true], + ["1.0.0-alpha.1.2.3", "<", "1.0.0-beta", true], + ["1.0.0-beta", ">", "1.0.0-alpha.1", true], + + "# Build metadata is ignored", + ["1.0.0+build1", "===", "1.0.0+build2", true], + ["1.0.0-alpha+build", "===", "1.0.0-alpha", true], + ["1.2.3+build.1-2", "===", "1.2.3", true], + ["1.0.0+build1", "!==", "1.0.0+build2", false], + ["1.0.0+build1", "<", "1.0.0+build2", false], + ["1.0.0+build1", ">", "1.0.0+build2", false], + ["1.0.0+build1", "<=", "1.0.0+build2", true], + ["1.0.0+build1", ">=", "1.0.0+build2", true], + ["1.0.0+build9", "<", "1.0.1+build1", true], + ["1.0.1+build1", ">", "1.0.0+build9", true], + + "# Partial versions", + ["1.2-alpha", "===", "1.2.0-alpha", true], + ["1.2-alpha", "<", "1.3.1", true], + ["1.2-alpha", "<", "1.2.0", true], + ["1-rc1", "<", "1.0.0", true], + ["1.2+build", "===", "1.2.0", true], + + "# Zero versions", + ["0.0.0", "===", "0.0.0", true], + ["0.0.0", "<", "0.0.1", true], + ["0", "===", "0.0.0", true], + + "# A version ending in a bare hyphen is rejected", + ["1.0.0-", "===", "1.0.0", false], + ["1.0.0-", "!==", "1.0.0", false], + ["1.2-", "===", "1.2.0", false], + ["1.2-", "!==", "1.2.0", false], + + "# Hyphens inside a pre-release are fine, since they are part of the pre-release identifier", + ["1.0.0-alpha-", "<", "1.0.0", true], + + "# Leading zeros are rejected", + ["01.2.3", "===", "1.2.3", false], + ["01.2.3", "!==", "1.2.3", false], + ["1.02.3", "===", "1.2.3", false], + ["1.02.3", "!==", "1.2.3", false], + ["1.2.03", "===", "1.2.3", false], + ["1.2.03", "!==", "1.2.3", false], + ["01.02.03", "===", "1.2.3", false], + ["01.02.03", "!==", "1.2.3", false], + + "# Leading zeros in a numeric pre-release are rejected too, SemVer 2.0.0 item 9", + ["1.2.3-01", "===", "1.2.3", false], + ["1.2.3-01", "!==", "1.2.3", false], + ["1.2.3-rc.01", "===", "1.2.3", false], + ["1.2.3-rc.01", "!==", "1.2.3", false], + + "# A lone zero is a legal pre-release identifier", + ["1.2.3-0", "<", "1.2.3", true], + + "# Digits inside a word are still valid", + ["1.2.3-rc01", "<", "1.2.3", true], + + "# A leading v is accepted, in either case", + ["V1.2.3", "===", "1.2.3", true], + ["v1.0.0-alpha", "<", "1.0.0", true], + ["v1.2.4", "!==", "1.2.3", true], + ["v1.2.3", "<=", "1.2.3", true], + ["v1.2.4", ">", "1.2.3", true], + ["v1.2.3", ">=", "1.2.3", true], + + "# Missing or wrong-typed values", + ["not-a-version", "===", "1.2.3", false], + [123, "===", "1.2.3", false], + [null, "===", "1.2.3", false], + + "# Malformed versions, checked under both symbols", + ["", "===", "1.2.3", false], + ["", "!==", "1.2.3", false], + ["v", "===", "1.2.3", false], + ["v", "!==", "1.2.3", false], + ["-1.2.3", "===", "1.2.3", false], + ["-1.2.3", "!==", "1.2.3", false], + ["1.", "===", "1.2.3", false], + ["1.", "!==", "1.2.3", false], + ["1.2.3.", "===", "1.2.3", false], + ["1.2.3.", "!==", "1.2.3", false], + ["1..2", "===", "1.2.3", false], + ["1..2", "!==", "1.2.3", false], + ["1.2.3.4", "===", "1.2.3", false], + ["1.2.3.4", "!==", "1.2.3", false], + ["^1.2.3", "===", "1.2.3", false], + ["^1.2.3", "!==", "1.2.3", false], + ["abc1.2.3", "===", "1.2.3", false], + ["abc1.2.3", "!==", "1.2.3", false], + ["1.2.3+", "===", "1.2.3", false], + ["1.2.3+", "!==", "1.2.3", false], + ["1.2.3-alpha..1", "===", "1.2.3", false], + ["1.2.3-alpha..1", "!==", "1.2.3", false], + ["1.2.3-.", "===", "1.2.3", false], + ["1.2.3-.", "!==", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "===", "1.2.3", false], + ["1.2.3-ALPHA_BETA", "!==", "1.2.3", false], + ["vv1.2.3", "===", "1.2.3", false], + ["vv1.2.3", "!==", "1.2.3", false], + + "# Operand length cap", + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "===", "1.2.3", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", false], + ["1.2.3", "!==", "1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false], + ["1.2.3-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "!==", "1.2.3", true] +] diff --git a/spec/mixpanel-ruby/flags/custom_operators_spec.rb b/spec/mixpanel-ruby/flags/custom_operators_spec.rb new file mode 100644 index 0000000..64a20fb --- /dev/null +++ b/spec/mixpanel-ruby/flags/custom_operators_spec.rb @@ -0,0 +1,74 @@ +require 'json' +require 'rspec' +require 'json_logic' +require 'mixpanel-ruby/flags/custom_operators' + +# The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and its +# README live in the analytics monorepo. Cases run through JsonLogic.apply so that operator +# registration is covered alongside the comparison itself. +# +# Defined at the top level so the case tables can be built while the describe blocks are collected. +FIXTURES = File.expand_path('../../fixtures', __dir__) + +# The property key the vectors are evaluated against. It is plumbing the spec supplies, so any name +# works as long as the rule and the data agree on it. +VECTOR_KEY = 'value'.freeze + +def rule_for(operator, symbol, target) + { "#{operator}_compare" => [{ 'var' => VECTOR_KEY }, symbol, target] } +end + +# Build the event the rule reads from, omitting the key entirely for an unset property. +def data_for(subject) + subject.nil? ? {} : { VECTOR_KEY => subject } +end + +# Read a golden-vector file. String entries are headings, array entries are cases. +def load_vectors(operator) + entries = JSON.parse(File.read(File.join(FIXTURES, "#{operator}_compare_tests.json"))) + + section = '' + cases = [] + entries.each_with_index do |entry, index| + if entry.is_a?(String) + section = entry + next + end + subject, symbol, target, want = entry + name = "#{index} #{section}: #{subject.to_json} #{symbol} #{target.to_json}" + cases << [name, rule_for(operator, symbol, target), data_for(subject), want] + end + cases +end + +SEMVER_CASES = load_vectors('semver') +DATETIME_CASES = load_vectors('datetime') + +describe Mixpanel::Flags::CustomOperators do + def apply(rule, data) + JsonLogic.apply(rule, data) + end + + describe 'semver_compare' do + SEMVER_CASES.each do |name, rule, data, want| + it name do + expect(apply(rule, data)).to eq(want) + end + end + end + + describe 'datetime_compare' do + DATETIME_CASES.each do |name, rule, data, want| + it name do + expect(apply(rule, data)).to eq(want) + end + end + end + + # An unset property must produce an event with no key at all, rather than a key holding a nil. + # Both spellings fail closed, so the vectors alone cannot tell them apart. + it 'omits the property for an unset subject' do + expect(data_for(nil)).to eq({}) + expect(data_for('1.2.3')).to eq({ VECTOR_KEY => '1.2.3' }) + end +end diff --git a/spec/mixpanel-ruby/flags/local_flags_spec.rb b/spec/mixpanel-ruby/flags/local_flags_spec.rb index 066b26b..e42ba5d 100644 --- a/spec/mixpanel-ruby/flags/local_flags_spec.rb +++ b/spec/mixpanel-ruby/flags/local_flags_spec.rb @@ -486,6 +486,68 @@ def user_context_with_properties(properties) expect(result).to eq('fallback') end + it 'respects runtime evaluation rule with semver_compare operator when satisfied' do + runtime_eval = { + 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'app_version' => '1.5.0'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with semver_compare operator when not satisfied' do + runtime_eval = { + 'semver_compare' => [{'var' => 'app_version'}, '>=', '1.2.0'] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'app_version' => '1.0.0'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + + it 'respects runtime evaluation rule with datetime_compare operator when satisfied' do + runtime_eval = { + 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'signup' => '2026-07-17T00:00:00Z'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).not_to eq('fallback') + expect(['control', 'treatment']).to include(result) + end + + it 'respects runtime evaluation rule with datetime_compare operator when not satisfied' do + runtime_eval = { + 'datetime_compare' => [{'var' => 'signup'}, '>=', 1_784_160_000_000] + } + flag = create_test_flag(runtime_evaluation_rule: runtime_eval) + + stub_flag_definitions([flag]) + provider.start_polling_for_definitions! + + context = user_context_with_properties({'signup' => '2026-07-15T00:00:00Z'}) + result = provider.get_variant_value('test_flag', 'fallback', context) + + expect(result).to eq('fallback') + end + it 'picks correct variant with hundred percent split' do variants = [ { 'key' => 'A', 'value' => 'variant_a', 'is_control' => false, 'split' => 100.0 },