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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +94 -0
  4. data/lib/quonfig/caching_http_connection.rb +3 -3
  5. data/lib/quonfig/client.rb +22 -27
  6. data/lib/quonfig/config_loader.rb +5 -1
  7. data/lib/quonfig/config_store.rb +10 -6
  8. data/lib/quonfig/context.rb +5 -4
  9. data/lib/quonfig/datadir.rb +4 -10
  10. data/lib/quonfig/dev_context.rb +4 -2
  11. data/lib/quonfig/duration.rb +2 -2
  12. data/lib/quonfig/encryption.rb +12 -16
  13. data/lib/quonfig/errors/invalid_environment_error.rb +1 -3
  14. data/lib/quonfig/errors/invalid_sdk_key_error.rb +6 -7
  15. data/lib/quonfig/errors/missing_env_var_error.rb +0 -3
  16. data/lib/quonfig/errors/missing_environment_error.rb +1 -1
  17. data/lib/quonfig/errors/uninitialized_error.rb +1 -1
  18. data/lib/quonfig/evaluation.rb +11 -8
  19. data/lib/quonfig/evaluator.rb +34 -37
  20. data/lib/quonfig/fixed_size_hash.rb +1 -0
  21. data/lib/quonfig/http_connection.rb +2 -4
  22. data/lib/quonfig/internal_logger.rb +63 -27
  23. data/lib/quonfig/murmer3.rb +2 -2
  24. data/lib/quonfig/options.rb +62 -75
  25. data/lib/quonfig/periodic_sync.rb +1 -1
  26. data/lib/quonfig/quonfig.rb +3 -3
  27. data/lib/quonfig/reason.rb +2 -1
  28. data/lib/quonfig/resolver.rb +8 -9
  29. data/lib/quonfig/semantic_logger_filter.rb +4 -3
  30. data/lib/quonfig/semver.rb +6 -8
  31. data/lib/quonfig/sse_config_client.rb +14 -15
  32. data/lib/quonfig/stdlib_formatter.rb +3 -3
  33. data/lib/quonfig/telemetry/context_shape_aggregator.rb +2 -3
  34. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +1 -1
  35. data/lib/quonfig/telemetry/telemetry_reporter.rb +1 -0
  36. data/lib/quonfig/time_helpers.rb +2 -0
  37. data/lib/quonfig/version.rb +5 -0
  38. data/lib/quonfig.rb +2 -1
  39. data/quonfig.gemspec +29 -165
  40. metadata +24 -193
  41. data/.claude/rules/constitution.md +0 -81
  42. data/.claude/rules/git-safety.md +0 -11
  43. data/.claude/rules/issue-tracking.md +0 -13
  44. data/.claude/rules/testing-workflow.md +0 -28
  45. data/.envrc.sample +0 -3
  46. data/.github/CODEOWNERS +0 -2
  47. data/.github/pull_request_template.md +0 -8
  48. data/.github/workflows/release.yml +0 -49
  49. data/.github/workflows/ruby.yml +0 -60
  50. data/.github/workflows/test.yaml +0 -40
  51. data/.rubocop.yml +0 -13
  52. data/.tool-versions +0 -1
  53. data/CLAUDE.md +0 -29
  54. data/CODEOWNERS +0 -1
  55. data/Gemfile +0 -26
  56. data/Gemfile.lock +0 -177
  57. data/Rakefile +0 -64
  58. data/VERSION +0 -1
  59. data/dev/allocation_stats +0 -60
  60. data/dev/benchmark +0 -40
  61. data/dev/console +0 -12
  62. data/dev/script_setup.rb +0 -18
  63. data/test/fixtures/datafile.json +0 -87
  64. data/test/integration/test_context_precedence.rb +0 -112
  65. data/test/integration/test_datadir_environment.rb +0 -54
  66. data/test/integration/test_dev_overrides.rb +0 -40
  67. data/test/integration/test_enabled.rb +0 -478
  68. data/test/integration/test_enabled_with_contexts.rb +0 -64
  69. data/test/integration/test_get.rb +0 -136
  70. data/test/integration/test_get_feature_flag.rb +0 -28
  71. data/test/integration/test_get_or_raise.rb +0 -60
  72. data/test/integration/test_get_weighted_values.rb +0 -34
  73. data/test/integration/test_helpers.rb +0 -667
  74. data/test/integration/test_helpers_test.rb +0 -73
  75. data/test/integration/test_post.rb +0 -44
  76. data/test/integration/test_telemetry.rb +0 -170
  77. data/test/support/common_helpers.rb +0 -106
  78. data/test/support/mock_base_client.rb +0 -27
  79. data/test/support/mock_config_loader.rb +0 -1
  80. data/test/test_bound_client.rb +0 -109
  81. data/test/test_caching_http_connection.rb +0 -218
  82. data/test/test_client.rb +0 -255
  83. data/test/test_client_network_mode.rb +0 -136
  84. data/test/test_client_telemetry.rb +0 -175
  85. data/test/test_config_loader.rb +0 -70
  86. data/test/test_context.rb +0 -139
  87. data/test/test_context_shape.rb +0 -37
  88. data/test/test_context_shape_aggregator.rb +0 -126
  89. data/test/test_datadir.rb +0 -203
  90. data/test/test_details_getters.rb +0 -242
  91. data/test/test_dev_context.rb +0 -163
  92. data/test/test_duration.rb +0 -37
  93. data/test/test_encryption.rb +0 -16
  94. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  95. data/test/test_evaluator.rb +0 -285
  96. data/test/test_example_contexts_aggregator.rb +0 -119
  97. data/test/test_exponential_backoff.rb +0 -44
  98. data/test/test_fixed_size_hash.rb +0 -119
  99. data/test/test_helper.rb +0 -17
  100. data/test/test_http_connection.rb +0 -81
  101. data/test/test_internal_logger.rb +0 -34
  102. data/test/test_options.rb +0 -198
  103. data/test/test_rate_limit_cache.rb +0 -44
  104. data/test/test_reason.rb +0 -79
  105. data/test/test_rename.rb +0 -65
  106. data/test/test_resolver.rb +0 -291
  107. data/test/test_semantic_logger_filter.rb +0 -144
  108. data/test/test_semver.rb +0 -108
  109. data/test/test_should_log.rb +0 -186
  110. data/test/test_sse_config_client.rb +0 -297
  111. data/test/test_stdlib_formatter.rb +0 -195
  112. data/test/test_telemetry_reporter.rb +0 -209
  113. data/test/test_typed_getters.rb +0 -131
  114. data/test/test_types.rb +0 -141
  115. 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
@@ -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