quonfig 0.0.10 → 0.0.12
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/CHANGELOG.md +30 -0
- data/README.md +94 -0
- data/lib/quonfig/caching_http_connection.rb +3 -3
- data/lib/quonfig/client.rb +22 -27
- data/lib/quonfig/config_loader.rb +5 -1
- data/lib/quonfig/config_store.rb +10 -6
- data/lib/quonfig/context.rb +5 -4
- data/lib/quonfig/datadir.rb +4 -10
- data/lib/quonfig/dev_context.rb +4 -2
- data/lib/quonfig/duration.rb +2 -2
- data/lib/quonfig/encryption.rb +12 -16
- data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
- data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
- data/lib/quonfig/errors/missing_environment_error.rb +1 -1
- data/lib/quonfig/errors/uninitialized_error.rb +1 -1
- data/lib/quonfig/evaluation.rb +11 -8
- data/lib/quonfig/evaluator.rb +34 -37
- data/lib/quonfig/fixed_size_hash.rb +1 -0
- data/lib/quonfig/http_connection.rb +2 -4
- data/lib/quonfig/internal_logger.rb +63 -27
- data/lib/quonfig/murmer3.rb +2 -2
- data/lib/quonfig/options.rb +62 -75
- data/lib/quonfig/periodic_sync.rb +1 -1
- data/lib/quonfig/quonfig.rb +3 -3
- data/lib/quonfig/reason.rb +2 -1
- data/lib/quonfig/resolver.rb +8 -9
- data/lib/quonfig/semantic_logger_filter.rb +4 -3
- data/lib/quonfig/semver.rb +6 -8
- data/lib/quonfig/sse_config_client.rb +14 -15
- data/lib/quonfig/stdlib_formatter.rb +3 -3
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
- data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
- data/lib/quonfig/time_helpers.rb +2 -0
- data/lib/quonfig/version.rb +5 -0
- data/lib/quonfig.rb +2 -1
- data/quonfig.gemspec +29 -165
- metadata +24 -193
- data/.claude/rules/constitution.md +0 -81
- data/.claude/rules/git-safety.md +0 -11
- data/.claude/rules/issue-tracking.md +0 -13
- data/.claude/rules/testing-workflow.md +0 -28
- data/.envrc.sample +0 -3
- data/.github/CODEOWNERS +0 -2
- data/.github/pull_request_template.md +0 -8
- data/.github/workflows/release.yml +0 -49
- data/.github/workflows/ruby.yml +0 -60
- data/.github/workflows/test.yaml +0 -40
- data/.rubocop.yml +0 -13
- data/.tool-versions +0 -1
- data/CLAUDE.md +0 -29
- data/CODEOWNERS +0 -1
- data/Gemfile +0 -26
- data/Gemfile.lock +0 -177
- data/Rakefile +0 -64
- data/VERSION +0 -1
- data/dev/allocation_stats +0 -60
- data/dev/benchmark +0 -40
- data/dev/console +0 -12
- data/dev/script_setup.rb +0 -18
- data/test/fixtures/datafile.json +0 -87
- data/test/integration/test_context_precedence.rb +0 -112
- data/test/integration/test_datadir_environment.rb +0 -54
- data/test/integration/test_dev_overrides.rb +0 -40
- data/test/integration/test_enabled.rb +0 -478
- data/test/integration/test_enabled_with_contexts.rb +0 -64
- data/test/integration/test_get.rb +0 -136
- data/test/integration/test_get_feature_flag.rb +0 -28
- data/test/integration/test_get_or_raise.rb +0 -60
- data/test/integration/test_get_weighted_values.rb +0 -34
- data/test/integration/test_helpers.rb +0 -667
- data/test/integration/test_helpers_test.rb +0 -73
- data/test/integration/test_post.rb +0 -44
- data/test/integration/test_telemetry.rb +0 -170
- data/test/support/common_helpers.rb +0 -106
- data/test/support/mock_base_client.rb +0 -27
- data/test/support/mock_config_loader.rb +0 -1
- data/test/test_bound_client.rb +0 -109
- data/test/test_caching_http_connection.rb +0 -218
- data/test/test_client.rb +0 -255
- data/test/test_client_network_mode.rb +0 -136
- data/test/test_client_telemetry.rb +0 -175
- data/test/test_config_loader.rb +0 -70
- data/test/test_context.rb +0 -139
- data/test/test_context_shape.rb +0 -37
- data/test/test_context_shape_aggregator.rb +0 -126
- data/test/test_datadir.rb +0 -203
- data/test/test_details_getters.rb +0 -242
- data/test/test_dev_context.rb +0 -163
- data/test/test_duration.rb +0 -37
- data/test/test_encryption.rb +0 -16
- data/test/test_evaluation_summaries_aggregator.rb +0 -180
- data/test/test_evaluator.rb +0 -285
- data/test/test_example_contexts_aggregator.rb +0 -119
- data/test/test_exponential_backoff.rb +0 -44
- data/test/test_fixed_size_hash.rb +0 -119
- data/test/test_helper.rb +0 -17
- data/test/test_http_connection.rb +0 -81
- data/test/test_internal_logger.rb +0 -34
- data/test/test_options.rb +0 -198
- data/test/test_rate_limit_cache.rb +0 -44
- data/test/test_reason.rb +0 -79
- data/test/test_rename.rb +0 -65
- data/test/test_resolver.rb +0 -291
- data/test/test_semantic_logger_filter.rb +0 -144
- data/test/test_semver.rb +0 -108
- data/test/test_should_log.rb +0 -186
- data/test/test_sse_config_client.rb +0 -297
- data/test/test_stdlib_formatter.rb +0 -195
- data/test/test_telemetry_reporter.rb +0 -209
- data/test/test_typed_getters.rb +0 -131
- data/test/test_types.rb +0 -141
- data/test/test_weighted_value_resolver.rb +0 -84
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
|
|
5
|
-
class TestEvaluationSummariesAggregator < Minitest::Test
|
|
6
|
-
def make_aggregator(max_keys: 100)
|
|
7
|
-
Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max_keys)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def record_eval(agg, overrides = {})
|
|
11
|
-
defaults = {
|
|
12
|
-
config_id: 'c1',
|
|
13
|
-
config_key: 'my-test-key',
|
|
14
|
-
config_type: 'config',
|
|
15
|
-
conditional_value_index: 0,
|
|
16
|
-
weighted_value_index: nil,
|
|
17
|
-
selected_value: 'hello',
|
|
18
|
-
reason: 1
|
|
19
|
-
}
|
|
20
|
-
agg.record(**defaults.merge(overrides))
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def test_record_dedupes_identical_evaluations_into_one_counter
|
|
24
|
-
agg = make_aggregator
|
|
25
|
-
|
|
26
|
-
record_eval(agg)
|
|
27
|
-
record_eval(agg)
|
|
28
|
-
record_eval(agg)
|
|
29
|
-
|
|
30
|
-
event = agg.drain_event
|
|
31
|
-
refute_nil event
|
|
32
|
-
summaries = event['summaries']['summaries']
|
|
33
|
-
assert_equal 1, summaries.size
|
|
34
|
-
|
|
35
|
-
counters = summaries[0]['counters']
|
|
36
|
-
assert_equal 1, counters.size
|
|
37
|
-
assert_equal 3, counters[0]['count']
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def test_record_creates_separate_counters_for_different_rule_indexes
|
|
41
|
-
agg = make_aggregator
|
|
42
|
-
|
|
43
|
-
record_eval(agg, conditional_value_index: 0)
|
|
44
|
-
record_eval(agg, conditional_value_index: 1)
|
|
45
|
-
record_eval(agg, conditional_value_index: 1)
|
|
46
|
-
|
|
47
|
-
summaries = agg.drain_event['summaries']['summaries']
|
|
48
|
-
assert_equal 1, summaries.size
|
|
49
|
-
|
|
50
|
-
counters = summaries[0]['counters']
|
|
51
|
-
assert_equal 2, counters.size
|
|
52
|
-
|
|
53
|
-
by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['count'] }
|
|
54
|
-
assert_equal 1, by_idx[0]
|
|
55
|
-
assert_equal 2, by_idx[1]
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def test_record_groups_by_config_key_and_type
|
|
59
|
-
agg = make_aggregator
|
|
60
|
-
|
|
61
|
-
record_eval(agg, config_key: 'alpha', config_type: 'config')
|
|
62
|
-
record_eval(agg, config_key: 'alpha', config_type: 'config')
|
|
63
|
-
record_eval(agg, config_key: 'beta', config_type: 'feature_flag')
|
|
64
|
-
|
|
65
|
-
summaries = agg.drain_event['summaries']['summaries']
|
|
66
|
-
assert_equal 2, summaries.size
|
|
67
|
-
|
|
68
|
-
by_key = summaries.each_with_object({}) { |s, h| h[s['key']] = s }
|
|
69
|
-
assert_equal 'config', by_key['alpha']['type']
|
|
70
|
-
assert_equal 'feature_flag', by_key['beta']['type']
|
|
71
|
-
assert_equal 2, by_key['alpha']['counters'][0]['count']
|
|
72
|
-
assert_equal 1, by_key['beta']['counters'][0]['count']
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def test_drain_event_nil_when_empty
|
|
76
|
-
agg = make_aggregator
|
|
77
|
-
assert_nil agg.drain_event
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def test_drain_clears_state
|
|
81
|
-
agg = make_aggregator
|
|
82
|
-
record_eval(agg)
|
|
83
|
-
|
|
84
|
-
refute_nil agg.drain_event
|
|
85
|
-
assert_nil agg.drain_event, 'second drain with no new records should be nil'
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def test_drain_event_wire_shape
|
|
89
|
-
agg = make_aggregator
|
|
90
|
-
|
|
91
|
-
record_eval(agg,
|
|
92
|
-
config_id: 'cid-42',
|
|
93
|
-
config_key: 'my-test-key',
|
|
94
|
-
config_type: 'config',
|
|
95
|
-
conditional_value_index: 1,
|
|
96
|
-
weighted_value_index: nil,
|
|
97
|
-
selected_value: 'my-test-value',
|
|
98
|
-
reason: 2)
|
|
99
|
-
|
|
100
|
-
event = agg.drain_event
|
|
101
|
-
refute_nil event
|
|
102
|
-
assert event.key?('summaries'), 'top-level event key is summaries'
|
|
103
|
-
|
|
104
|
-
inner = event['summaries']
|
|
105
|
-
assert_kind_of Integer, inner['start']
|
|
106
|
-
assert_kind_of Integer, inner['end']
|
|
107
|
-
assert inner['end'] >= inner['start']
|
|
108
|
-
|
|
109
|
-
summary = inner['summaries'][0]
|
|
110
|
-
assert_equal 'my-test-key', summary['key']
|
|
111
|
-
assert_equal 'config', summary['type']
|
|
112
|
-
|
|
113
|
-
counter = summary['counters'][0]
|
|
114
|
-
assert_equal 'cid-42', counter['configId']
|
|
115
|
-
assert_equal 1, counter['conditionalValueIndex']
|
|
116
|
-
assert_equal 0, counter['configRowIndex']
|
|
117
|
-
assert_equal 1, counter['count']
|
|
118
|
-
assert_equal 2, counter['reason']
|
|
119
|
-
assert_equal({ 'string' => 'my-test-value' }, counter['selectedValue'])
|
|
120
|
-
refute counter.key?('weightedValueIndex'),
|
|
121
|
-
'weightedValueIndex omitted when nil'
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def test_selected_value_wrapper_keys_match_prefab_shape
|
|
125
|
-
agg = make_aggregator
|
|
126
|
-
|
|
127
|
-
record_eval(agg, selected_value: true, conditional_value_index: 0)
|
|
128
|
-
record_eval(agg, selected_value: 3, conditional_value_index: 1)
|
|
129
|
-
record_eval(agg, selected_value: 1.5, conditional_value_index: 2)
|
|
130
|
-
record_eval(agg, selected_value: 'hi', conditional_value_index: 3)
|
|
131
|
-
record_eval(agg, selected_value: %w[a b], conditional_value_index: 4)
|
|
132
|
-
|
|
133
|
-
counters = agg.drain_event['summaries']['summaries'][0]['counters']
|
|
134
|
-
by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['selectedValue'] }
|
|
135
|
-
|
|
136
|
-
assert_equal({ 'bool' => true }, by_idx[0])
|
|
137
|
-
assert_equal({ 'int' => 3 }, by_idx[1])
|
|
138
|
-
assert_equal({ 'double' => 1.5 }, by_idx[2])
|
|
139
|
-
assert_equal({ 'string' => 'hi' }, by_idx[3])
|
|
140
|
-
assert_equal({ 'stringList' => %w[a b] }, by_idx[4])
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def test_weighted_value_index_included_when_present
|
|
144
|
-
agg = make_aggregator
|
|
145
|
-
|
|
146
|
-
record_eval(agg, weighted_value_index: 2, reason: 3)
|
|
147
|
-
|
|
148
|
-
counter = agg.drain_event['summaries']['summaries'][0]['counters'][0]
|
|
149
|
-
assert_equal 2, counter['weightedValueIndex']
|
|
150
|
-
assert_equal 3, counter['reason']
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def test_log_level_evaluations_are_excluded
|
|
154
|
-
agg = make_aggregator
|
|
155
|
-
|
|
156
|
-
record_eval(agg, config_type: 'log_level')
|
|
157
|
-
|
|
158
|
-
assert_nil agg.drain_event
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
def test_record_caps_at_max_keys
|
|
162
|
-
agg = make_aggregator(max_keys: 2)
|
|
163
|
-
|
|
164
|
-
record_eval(agg, config_key: 'a')
|
|
165
|
-
record_eval(agg, config_key: 'b')
|
|
166
|
-
record_eval(agg, config_key: 'c') # dropped — at cap
|
|
167
|
-
|
|
168
|
-
summaries = agg.drain_event['summaries']['summaries']
|
|
169
|
-
keys = summaries.map { |s| s['key'] }.sort
|
|
170
|
-
assert_equal %w[a b], keys
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def test_noop_when_max_keys_zero
|
|
174
|
-
agg = make_aggregator(max_keys: 0)
|
|
175
|
-
|
|
176
|
-
record_eval(agg)
|
|
177
|
-
|
|
178
|
-
assert_nil agg.drain_event
|
|
179
|
-
end
|
|
180
|
-
end
|
data/test/test_evaluator.rb
DELETED
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
|
|
5
|
-
# qfg-dk6.10: operator-level tests for Quonfig::Evaluator against the JSON
|
|
6
|
-
# Criterion shape (propertyName / operator / valueToMatch). Mirrors sdk-node's
|
|
7
|
-
# evaluator behaviour — the node suite is the authoritative spec.
|
|
8
|
-
#
|
|
9
|
-
# The Evaluator consumes configs in the shape produced by
|
|
10
|
-
# IntegrationTestHelpers.to_config_response / Quonfig::Datadir.to_config_response:
|
|
11
|
-
# {
|
|
12
|
-
# id:, key:, type:, value_type:,
|
|
13
|
-
# send_to_client_sdk:, default: { 'rules' => [...] }, environment: {...}
|
|
14
|
-
# }
|
|
15
|
-
# Rules/criteria inside stay as plain JSON hashes (string keys), matching what
|
|
16
|
-
# lands on disk in integration-test-data.
|
|
17
|
-
class TestEvaluator < Minitest::Test
|
|
18
|
-
def build_config(rules, key: 'my.key', value_type: 'string', environment: nil)
|
|
19
|
-
{
|
|
20
|
-
id: 'id-1',
|
|
21
|
-
key: key,
|
|
22
|
-
type: 'config',
|
|
23
|
-
value_type: value_type,
|
|
24
|
-
send_to_client_sdk: false,
|
|
25
|
-
default: { 'rules' => rules },
|
|
26
|
-
environment: environment
|
|
27
|
-
}
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def evaluate(config, context_hash = {}, extra_configs: {})
|
|
31
|
-
store = Quonfig::ConfigStore.new
|
|
32
|
-
store.set(config[:key], config)
|
|
33
|
-
extra_configs.each { |k, v| store.set(k, v) }
|
|
34
|
-
evaluator = Quonfig::Evaluator.new(store)
|
|
35
|
-
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
36
|
-
resolver.get(config[:key], Quonfig::Context.new(context_hash))
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def value_match_rule(criteria, value_type, value)
|
|
40
|
-
{
|
|
41
|
-
'criteria' => criteria,
|
|
42
|
-
'value' => { 'type' => value_type, 'value' => value }
|
|
43
|
-
}
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# ------ ALWAYS_TRUE ------
|
|
47
|
-
|
|
48
|
-
def test_always_true
|
|
49
|
-
cfg = build_config([
|
|
50
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'hit')
|
|
51
|
-
])
|
|
52
|
-
assert_equal 'hit', evaluate(cfg).unwrapped_value
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# ------ PROP_IS_ONE_OF / PROP_IS_NOT_ONE_OF ------
|
|
56
|
-
|
|
57
|
-
def test_prop_is_one_of_matches
|
|
58
|
-
cfg = build_config([
|
|
59
|
-
value_match_rule(
|
|
60
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_IS_ONE_OF',
|
|
61
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => %w[a@b.com c@d.com] } }],
|
|
62
|
-
'string', 'match'
|
|
63
|
-
),
|
|
64
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'nope')
|
|
65
|
-
])
|
|
66
|
-
assert_equal 'match', evaluate(cfg, { user: { email: 'a@b.com' } }).unwrapped_value
|
|
67
|
-
assert_equal 'nope', evaluate(cfg, { user: { email: 'z@z.com' } }).unwrapped_value
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def test_prop_is_not_one_of_inverse
|
|
71
|
-
cfg = build_config([
|
|
72
|
-
value_match_rule(
|
|
73
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_IS_NOT_ONE_OF',
|
|
74
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => %w[a@b.com] } }],
|
|
75
|
-
'string', 'match'
|
|
76
|
-
),
|
|
77
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'nope')
|
|
78
|
-
])
|
|
79
|
-
# Missing context => NOT_ONE_OF is true (sdk-node parity)
|
|
80
|
-
assert_equal 'match', evaluate(cfg, {}).unwrapped_value
|
|
81
|
-
assert_equal 'nope', evaluate(cfg, { user: { email: 'a@b.com' } }).unwrapped_value
|
|
82
|
-
assert_equal 'match', evaluate(cfg, { user: { email: 'z@z.com' } }).unwrapped_value
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# ------ PROP_STARTS_WITH / ENDS_WITH / CONTAINS ------
|
|
86
|
-
|
|
87
|
-
def test_starts_with
|
|
88
|
-
cfg = build_config([
|
|
89
|
-
value_match_rule(
|
|
90
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_STARTS_WITH_ONE_OF',
|
|
91
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => %w[admin- root-] } }],
|
|
92
|
-
'string', 'yes'
|
|
93
|
-
),
|
|
94
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
95
|
-
])
|
|
96
|
-
assert_equal 'yes', evaluate(cfg, { user: { email: 'admin-bob' } }).unwrapped_value
|
|
97
|
-
assert_equal 'no', evaluate(cfg, { user: { email: 'bob' } }).unwrapped_value
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def test_ends_with
|
|
101
|
-
cfg = build_config([
|
|
102
|
-
value_match_rule(
|
|
103
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_ENDS_WITH_ONE_OF',
|
|
104
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => ['@prefab.cloud'] } }],
|
|
105
|
-
'string', 'yes'
|
|
106
|
-
),
|
|
107
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
108
|
-
])
|
|
109
|
-
assert_equal 'yes', evaluate(cfg, { user: { email: 'b@prefab.cloud' } }).unwrapped_value
|
|
110
|
-
assert_equal 'no', evaluate(cfg, { user: { email: 'b@other.com' } }).unwrapped_value
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def test_contains
|
|
114
|
-
cfg = build_config([
|
|
115
|
-
value_match_rule(
|
|
116
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_CONTAINS_ONE_OF',
|
|
117
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => ['admin'] } }],
|
|
118
|
-
'string', 'yes'
|
|
119
|
-
),
|
|
120
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
121
|
-
])
|
|
122
|
-
assert_equal 'yes', evaluate(cfg, { user: { email: 'admin@x.com' } }).unwrapped_value
|
|
123
|
-
assert_equal 'no', evaluate(cfg, { user: { email: 'b@x.com' } }).unwrapped_value
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# ------ PROP_MATCHES / DOES_NOT_MATCH ------
|
|
127
|
-
|
|
128
|
-
def test_prop_matches
|
|
129
|
-
cfg = build_config([
|
|
130
|
-
value_match_rule(
|
|
131
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_MATCHES',
|
|
132
|
-
'valueToMatch' => { 'type' => 'string', 'value' => '^admin' } }],
|
|
133
|
-
'string', 'yes'
|
|
134
|
-
),
|
|
135
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
136
|
-
])
|
|
137
|
-
assert_equal 'yes', evaluate(cfg, { user: { email: 'admin-foo' } }).unwrapped_value
|
|
138
|
-
assert_equal 'no', evaluate(cfg, { user: { email: 'foo' } }).unwrapped_value
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
# ------ HIERARCHICAL_MATCH ------
|
|
142
|
-
|
|
143
|
-
def test_hierarchical_match
|
|
144
|
-
cfg = build_config([
|
|
145
|
-
value_match_rule(
|
|
146
|
-
[{ 'propertyName' => 'team.path', 'operator' => 'HIERARCHICAL_MATCH',
|
|
147
|
-
'valueToMatch' => { 'type' => 'string', 'value' => 'orgs/a' } }],
|
|
148
|
-
'string', 'yes'
|
|
149
|
-
),
|
|
150
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
151
|
-
])
|
|
152
|
-
assert_equal 'yes', evaluate(cfg, { team: { path: 'orgs/a/team1' } }).unwrapped_value
|
|
153
|
-
assert_equal 'no', evaluate(cfg, { team: { path: 'orgs/b/team1' } }).unwrapped_value
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# ------ IN_INT_RANGE ------
|
|
157
|
-
|
|
158
|
-
def test_in_int_range
|
|
159
|
-
cfg = build_config([
|
|
160
|
-
value_match_rule(
|
|
161
|
-
[{ 'propertyName' => 'user.age', 'operator' => 'IN_INT_RANGE',
|
|
162
|
-
'valueToMatch' => { 'type' => 'int_range', 'value' => { 'start' => 18, 'end' => 30 } } }],
|
|
163
|
-
'string', 'yes'
|
|
164
|
-
),
|
|
165
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
166
|
-
])
|
|
167
|
-
assert_equal 'yes', evaluate(cfg, { user: { age: 20 } }).unwrapped_value
|
|
168
|
-
assert_equal 'no', evaluate(cfg, { user: { age: 30 } }).unwrapped_value # end exclusive
|
|
169
|
-
assert_equal 'yes', evaluate(cfg, { user: { age: 18 } }).unwrapped_value # start inclusive
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# ------ NUMERIC COMPARISONS ------
|
|
173
|
-
|
|
174
|
-
def test_prop_greater_than_lt_etc
|
|
175
|
-
%w[PROP_GREATER_THAN PROP_GREATER_THAN_OR_EQUAL PROP_LESS_THAN PROP_LESS_THAN_OR_EQUAL].each do |op|
|
|
176
|
-
cfg = build_config([
|
|
177
|
-
value_match_rule(
|
|
178
|
-
[{ 'propertyName' => 'user.age', 'operator' => op,
|
|
179
|
-
'valueToMatch' => { 'type' => 'int', 'value' => 10 } }],
|
|
180
|
-
'string', 'yes'
|
|
181
|
-
),
|
|
182
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
183
|
-
])
|
|
184
|
-
expected = case op
|
|
185
|
-
when 'PROP_GREATER_THAN' then { 9 => 'no', 10 => 'no', 11 => 'yes' }
|
|
186
|
-
when 'PROP_GREATER_THAN_OR_EQUAL' then { 9 => 'no', 10 => 'yes', 11 => 'yes' }
|
|
187
|
-
when 'PROP_LESS_THAN' then { 9 => 'yes', 10 => 'no', 11 => 'no' }
|
|
188
|
-
when 'PROP_LESS_THAN_OR_EQUAL' then { 9 => 'yes', 10 => 'yes', 11 => 'no' }
|
|
189
|
-
end
|
|
190
|
-
expected.each do |age, want|
|
|
191
|
-
assert_equal want, evaluate(cfg, { user: { age: age } }).unwrapped_value,
|
|
192
|
-
"op=#{op} age=#{age}"
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
# ------ DATE COMPARISONS (BEFORE / AFTER) ------
|
|
198
|
-
|
|
199
|
-
def test_prop_before_after
|
|
200
|
-
cfg_before = build_config([
|
|
201
|
-
value_match_rule(
|
|
202
|
-
[{ 'propertyName' => 'user.created_at', 'operator' => 'PROP_BEFORE',
|
|
203
|
-
'valueToMatch' => { 'type' => 'string', 'value' => '2025-01-01T00:00:00Z' } }],
|
|
204
|
-
'string', 'yes'
|
|
205
|
-
),
|
|
206
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
207
|
-
])
|
|
208
|
-
assert_equal 'yes', evaluate(cfg_before, { user: { created_at: '2024-06-01T00:00:00Z' } }).unwrapped_value
|
|
209
|
-
assert_equal 'no', evaluate(cfg_before, { user: { created_at: '2025-06-01T00:00:00Z' } }).unwrapped_value
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
# ------ SEMVER COMPARISONS ------
|
|
213
|
-
|
|
214
|
-
def test_semver_eq_gt_lt
|
|
215
|
-
[
|
|
216
|
-
['PROP_SEMVER_EQUAL', '1.2.3', '1.2.3', 'yes'],
|
|
217
|
-
['PROP_SEMVER_EQUAL', '1.2.4', '1.2.3', 'no'],
|
|
218
|
-
['PROP_SEMVER_GREATER_THAN', '1.2.4', '1.2.3', 'yes'],
|
|
219
|
-
['PROP_SEMVER_GREATER_THAN', '1.2.3', '1.2.4', 'no'],
|
|
220
|
-
['PROP_SEMVER_LESS_THAN', '1.2.2', '1.2.3', 'yes'],
|
|
221
|
-
['PROP_SEMVER_LESS_THAN', '1.2.4', '1.2.3', 'no']
|
|
222
|
-
].each do |op, context_ver, match_ver, want|
|
|
223
|
-
cfg = build_config([
|
|
224
|
-
value_match_rule(
|
|
225
|
-
[{ 'propertyName' => 'app.version', 'operator' => op,
|
|
226
|
-
'valueToMatch' => { 'type' => 'string', 'value' => match_ver } }],
|
|
227
|
-
'string', 'yes'
|
|
228
|
-
),
|
|
229
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'no')
|
|
230
|
-
])
|
|
231
|
-
assert_equal want, evaluate(cfg, { app: { version: context_ver } }).unwrapped_value,
|
|
232
|
-
"op=#{op} ctx=#{context_ver} match=#{match_ver}"
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
# ------ IN_SEG / NOT_IN_SEG ------
|
|
237
|
-
|
|
238
|
-
def test_in_seg_resolves_via_store
|
|
239
|
-
segment = build_config([
|
|
240
|
-
value_match_rule(
|
|
241
|
-
[{ 'propertyName' => 'user.email', 'operator' => 'PROP_IS_ONE_OF',
|
|
242
|
-
'valueToMatch' => { 'type' => 'string_list', 'value' => ['ok@x.com'] } }],
|
|
243
|
-
'bool', true
|
|
244
|
-
),
|
|
245
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'bool', false)
|
|
246
|
-
], key: 'seg.key', value_type: 'bool')
|
|
247
|
-
|
|
248
|
-
cfg = build_config([
|
|
249
|
-
value_match_rule(
|
|
250
|
-
[{ 'propertyName' => '', 'operator' => 'IN_SEG',
|
|
251
|
-
'valueToMatch' => { 'type' => 'string', 'value' => 'seg.key' } }],
|
|
252
|
-
'string', 'in'
|
|
253
|
-
),
|
|
254
|
-
value_match_rule([{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'out')
|
|
255
|
-
])
|
|
256
|
-
|
|
257
|
-
extra = { 'seg.key' => segment }
|
|
258
|
-
assert_equal 'in', evaluate(cfg, { user: { email: 'ok@x.com' } }, extra_configs: extra).unwrapped_value
|
|
259
|
-
assert_equal 'out', evaluate(cfg, { user: { email: 'no@x.com' } }, extra_configs: extra).unwrapped_value
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# ------ ENVIRONMENT-SPECIFIC RULES ------
|
|
263
|
-
|
|
264
|
-
def test_environment_rules_precede_default
|
|
265
|
-
env_rule = value_match_rule(
|
|
266
|
-
[{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'from-env'
|
|
267
|
-
)
|
|
268
|
-
default_rule = value_match_rule(
|
|
269
|
-
[{ 'operator' => 'ALWAYS_TRUE' }], 'string', 'from-default'
|
|
270
|
-
)
|
|
271
|
-
cfg = build_config(
|
|
272
|
-
[default_rule],
|
|
273
|
-
environment: { 'id' => 'Production', 'rules' => [env_rule] }
|
|
274
|
-
)
|
|
275
|
-
# default evaluator has no envID set, so falls back to default rules
|
|
276
|
-
assert_equal 'from-default', evaluate(cfg).unwrapped_value
|
|
277
|
-
|
|
278
|
-
store = Quonfig::ConfigStore.new
|
|
279
|
-
store.set(cfg[:key], cfg)
|
|
280
|
-
evaluator = Quonfig::Evaluator.new(store, env_id: 'Production')
|
|
281
|
-
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
282
|
-
result = resolver.get(cfg[:key], Quonfig::Context.new({}))
|
|
283
|
-
assert_equal 'from-env', result.unwrapped_value
|
|
284
|
-
end
|
|
285
|
-
end
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
require 'timecop'
|
|
5
|
-
|
|
6
|
-
class TestExampleContextsAggregator < Minitest::Test
|
|
7
|
-
def test_record_dedupes_within_window
|
|
8
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 2)
|
|
9
|
-
|
|
10
|
-
context = Quonfig::Context.new(
|
|
11
|
-
'user' => { 'key' => 'abc' },
|
|
12
|
-
'device' => { 'key' => 'def', 'mobile' => true }
|
|
13
|
-
)
|
|
14
|
-
|
|
15
|
-
aggregator.record(context)
|
|
16
|
-
assert_equal 1, aggregator.data.size
|
|
17
|
-
|
|
18
|
-
# Same grouped key → skipped.
|
|
19
|
-
aggregator.record(context)
|
|
20
|
-
assert_equal 1, aggregator.data.size
|
|
21
|
-
|
|
22
|
-
new_context = Quonfig::Context.new(
|
|
23
|
-
'user' => { 'key' => 'ghi', 'admin' => true },
|
|
24
|
-
'team' => { 'key' => '999' }
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
aggregator.record(new_context)
|
|
28
|
-
assert_equal 2, aggregator.data.size
|
|
29
|
-
|
|
30
|
-
# At max_contexts — next record is dropped.
|
|
31
|
-
aggregator.record(Quonfig::Context.new('user' => { 'key' => 'new' }))
|
|
32
|
-
assert_equal 2, aggregator.data.size
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def test_record_drops_contexts_without_a_key_property
|
|
36
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
37
|
-
|
|
38
|
-
# Anonymous contexts (no `key` / `trackingId`) produce an empty
|
|
39
|
-
# grouped_key under the sdk-node-aligned shape. The aggregator drops
|
|
40
|
-
# them so we don't ship rows the backend can't dedupe.
|
|
41
|
-
aggregator.record(Quonfig::Context.new('user' => { 'name' => 'no-key' }))
|
|
42
|
-
aggregator.record(Quonfig::Context.new('user' => { 'name' => 'still-no-key' }))
|
|
43
|
-
assert_equal 0, aggregator.data.size
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def test_record_with_expiry_allows_re_recording_after_window
|
|
47
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
48
|
-
|
|
49
|
-
context = Quonfig::Context.new(
|
|
50
|
-
'user' => { 'key' => 'abc' },
|
|
51
|
-
'device' => { 'key' => 'def', 'mobile' => true }
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
aggregator.record(context)
|
|
55
|
-
assert_equal 1, aggregator.data.size
|
|
56
|
-
|
|
57
|
-
Timecop.travel(Time.now + (60 * 60) - 1) do
|
|
58
|
-
aggregator.record(context)
|
|
59
|
-
assert_equal 1, aggregator.data.size
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
Timecop.travel(Time.now + (60 * 60) + 1) do
|
|
63
|
-
aggregator.record(context)
|
|
64
|
-
assert_equal 2, aggregator.data.size
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def test_drain_event_emits_api_telemetry_wire_shape
|
|
69
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
70
|
-
|
|
71
|
-
aggregator.record(
|
|
72
|
-
Quonfig::Context.new(
|
|
73
|
-
'user' => { 'key' => 'abc' },
|
|
74
|
-
'device' => { 'key' => 'def', 'mobile' => true }
|
|
75
|
-
)
|
|
76
|
-
)
|
|
77
|
-
aggregator.record(
|
|
78
|
-
Quonfig::Context.new('user' => { 'key' => 'kev', 'name' => 'kevin', 'age' => 48.5 })
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
event = aggregator.drain_event
|
|
82
|
-
|
|
83
|
-
refute_nil event
|
|
84
|
-
assert event.key?('exampleContexts')
|
|
85
|
-
examples = event['exampleContexts']['examples']
|
|
86
|
-
assert_equal 2, examples.size
|
|
87
|
-
|
|
88
|
-
first = examples[0]
|
|
89
|
-
assert_kind_of Integer, first['timestamp']
|
|
90
|
-
assert first['timestamp'] > 0
|
|
91
|
-
|
|
92
|
-
contexts_list = first['contextSet']['contexts']
|
|
93
|
-
user_ctx = contexts_list.find { |c| c['type'] == 'user' }
|
|
94
|
-
device_ctx = contexts_list.find { |c| c['type'] == 'device' }
|
|
95
|
-
|
|
96
|
-
refute_nil user_ctx
|
|
97
|
-
refute_nil device_ctx
|
|
98
|
-
assert_equal 'abc', user_ctx['values']['key']
|
|
99
|
-
assert_equal true, device_ctx['values']['mobile']
|
|
100
|
-
|
|
101
|
-
second = examples[1]
|
|
102
|
-
user_ctx = second['contextSet']['contexts'].find { |c| c['type'] == 'user' }
|
|
103
|
-
assert_equal 'kev', user_ctx['values']['key']
|
|
104
|
-
assert_equal 'kevin', user_ctx['values']['name']
|
|
105
|
-
assert_in_delta 48.5, user_ctx['values']['age']
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def test_drain_event_nil_when_empty
|
|
109
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
110
|
-
assert_nil aggregator.drain_event
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def test_drain_clears_data
|
|
114
|
-
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
115
|
-
aggregator.record(Quonfig::Context.new('user' => { 'key' => 'abc' }))
|
|
116
|
-
aggregator.drain_event
|
|
117
|
-
assert_equal 0, aggregator.data.size
|
|
118
|
-
end
|
|
119
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test_helper'
|
|
4
|
-
|
|
5
|
-
class TestExponentialBackoff < Minitest::Test
|
|
6
|
-
def test_backoff
|
|
7
|
-
backoff = Quonfig::ExponentialBackoff.new(max_delay: 120)
|
|
8
|
-
|
|
9
|
-
assert_equal 2, backoff.call
|
|
10
|
-
assert_equal 4, backoff.call
|
|
11
|
-
assert_equal 8, backoff.call
|
|
12
|
-
assert_equal 16, backoff.call
|
|
13
|
-
assert_equal 32, backoff.call
|
|
14
|
-
assert_equal 64, backoff.call
|
|
15
|
-
assert_equal 120, backoff.call
|
|
16
|
-
assert_equal 120, backoff.call
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def test_backoff_with_15x_multiplier_matches_quonfig_spec
|
|
20
|
-
# Spec: initial 8s, multiplier 1.5, max 600s (matches sdk-node reporter.ts)
|
|
21
|
-
backoff = Quonfig::ExponentialBackoff.new(
|
|
22
|
-
initial_delay: 8, max_delay: 600, multiplier: 1.5
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
assert_equal 8, backoff.call
|
|
26
|
-
assert_equal 12.0, backoff.call
|
|
27
|
-
assert_equal 18.0, backoff.call
|
|
28
|
-
assert_equal 27.0, backoff.call
|
|
29
|
-
assert_equal 40.5, backoff.call
|
|
30
|
-
assert_equal 60.75, backoff.call
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def test_periodic_sync_default_matches_quonfig_spec
|
|
34
|
-
mod = Class.new { include Quonfig::PeriodicSync }.new
|
|
35
|
-
interval = mod.send(:calculate_sync_interval, nil)
|
|
36
|
-
|
|
37
|
-
# Spec: initial 8s, multiplier 1.5, max 600s
|
|
38
|
-
assert_equal 8, interval.call
|
|
39
|
-
assert_equal 12.0, interval.call
|
|
40
|
-
assert_equal 18.0, interval.call
|
|
41
|
-
assert_equal 27.0, interval.call
|
|
42
|
-
assert_equal 40.5, interval.call
|
|
43
|
-
end
|
|
44
|
-
end
|