quonfig 0.0.2

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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/rules/constitution.md +81 -0
  3. data/.claude/rules/git-safety.md +11 -0
  4. data/.claude/rules/issue-tracking.md +13 -0
  5. data/.claude/rules/testing-workflow.md +28 -0
  6. data/.envrc.sample +3 -0
  7. data/.github/CODEOWNERS +2 -0
  8. data/.github/pull_request_template.md +8 -0
  9. data/.github/workflows/push_gem.yml +49 -0
  10. data/.github/workflows/ruby.yml +60 -0
  11. data/.github/workflows/test.yaml +40 -0
  12. data/.rubocop.yml +13 -0
  13. data/.tool-versions +1 -0
  14. data/CHANGELOG.md +301 -0
  15. data/CLAUDE.md +29 -0
  16. data/CODEOWNERS +1 -0
  17. data/Gemfile +26 -0
  18. data/Gemfile.lock +177 -0
  19. data/LICENSE.txt +20 -0
  20. data/README.md +213 -0
  21. data/Rakefile +64 -0
  22. data/VERSION +1 -0
  23. data/dev/allocation_stats +60 -0
  24. data/dev/benchmark +40 -0
  25. data/dev/console +12 -0
  26. data/dev/script_setup.rb +18 -0
  27. data/lib/quonfig/bound_client.rb +71 -0
  28. data/lib/quonfig/caching_http_connection.rb +95 -0
  29. data/lib/quonfig/client.rb +221 -0
  30. data/lib/quonfig/config_envelope.rb +5 -0
  31. data/lib/quonfig/config_loader.rb +103 -0
  32. data/lib/quonfig/config_store.rb +42 -0
  33. data/lib/quonfig/context.rb +101 -0
  34. data/lib/quonfig/datadir.rb +101 -0
  35. data/lib/quonfig/duration.rb +58 -0
  36. data/lib/quonfig/encryption.rb +74 -0
  37. data/lib/quonfig/error.rb +6 -0
  38. data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
  39. data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/quonfig/errors/missing_default_error.rb +13 -0
  42. data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
  43. data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
  44. data/lib/quonfig/errors/uninitialized_error.rb +13 -0
  45. data/lib/quonfig/evaluation.rb +64 -0
  46. data/lib/quonfig/evaluator.rb +464 -0
  47. data/lib/quonfig/exponential_backoff.rb +21 -0
  48. data/lib/quonfig/fixed_size_hash.rb +14 -0
  49. data/lib/quonfig/http_connection.rb +46 -0
  50. data/lib/quonfig/internal_logger.rb +173 -0
  51. data/lib/quonfig/murmer3.rb +50 -0
  52. data/lib/quonfig/options.rb +194 -0
  53. data/lib/quonfig/periodic_sync.rb +74 -0
  54. data/lib/quonfig/quonfig.rb +58 -0
  55. data/lib/quonfig/rate_limit_cache.rb +41 -0
  56. data/lib/quonfig/reason.rb +39 -0
  57. data/lib/quonfig/resolver.rb +42 -0
  58. data/lib/quonfig/semantic_logger_filter.rb +90 -0
  59. data/lib/quonfig/semver.rb +132 -0
  60. data/lib/quonfig/sse_config_client.rb +135 -0
  61. data/lib/quonfig/time_helpers.rb +7 -0
  62. data/lib/quonfig/types.rb +56 -0
  63. data/lib/quonfig/weighted_value_resolver.rb +49 -0
  64. data/lib/quonfig.rb +57 -0
  65. data/quonfig.gemspec +149 -0
  66. data/scripts/generate_integration_tests.rb +362 -0
  67. data/test/fixtures/datafile.json +87 -0
  68. data/test/integration/test_context_precedence.rb +194 -0
  69. data/test/integration/test_datadir_environment.rb +76 -0
  70. data/test/integration/test_enabled.rb +784 -0
  71. data/test/integration/test_enabled_with_contexts.rb +94 -0
  72. data/test/integration/test_get.rb +224 -0
  73. data/test/integration/test_get_feature_flag.rb +34 -0
  74. data/test/integration/test_get_or_raise.rb +86 -0
  75. data/test/integration/test_get_weighted_values.rb +29 -0
  76. data/test/integration/test_helpers.rb +139 -0
  77. data/test/integration/test_helpers_test.rb +73 -0
  78. data/test/integration/test_post.rb +34 -0
  79. data/test/integration/test_telemetry.rb +114 -0
  80. data/test/support/common_helpers.rb +106 -0
  81. data/test/support/mock_base_client.rb +27 -0
  82. data/test/support/mock_config_loader.rb +1 -0
  83. data/test/test_bound_client.rb +109 -0
  84. data/test/test_caching_http_connection.rb +218 -0
  85. data/test/test_client.rb +255 -0
  86. data/test/test_config_loader.rb +70 -0
  87. data/test/test_context.rb +136 -0
  88. data/test/test_datadir.rb +199 -0
  89. data/test/test_duration.rb +37 -0
  90. data/test/test_encryption.rb +16 -0
  91. data/test/test_evaluator.rb +285 -0
  92. data/test/test_exponential_backoff.rb +44 -0
  93. data/test/test_fixed_size_hash.rb +119 -0
  94. data/test/test_helper.rb +17 -0
  95. data/test/test_http_connection.rb +79 -0
  96. data/test/test_internal_logger.rb +34 -0
  97. data/test/test_options.rb +167 -0
  98. data/test/test_rate_limit_cache.rb +44 -0
  99. data/test/test_reason.rb +79 -0
  100. data/test/test_rename.rb +65 -0
  101. data/test/test_resolver.rb +144 -0
  102. data/test/test_semantic_logger_filter.rb +123 -0
  103. data/test/test_semver.rb +108 -0
  104. data/test/test_sse_config_client.rb +297 -0
  105. data/test/test_typed_getters.rb +131 -0
  106. data/test/test_types.rb +141 -0
  107. data/test/test_weighted_value_resolver.rb +84 -0
  108. metadata +311 -0
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class TestEncryption < Minitest::Test
6
+ def test_encryption
7
+ secret = Quonfig::Encryption.generate_new_hex_key
8
+
9
+ enc = Quonfig::Encryption.new(secret)
10
+
11
+ clear_text = "hello world"
12
+ encrypted = enc.encrypt(clear_text)
13
+ decrypted = enc.decrypt(encrypted)
14
+ assert_equal clear_text, decrypted
15
+ end
16
+ end
@@ -0,0 +1,285 @@
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
@@ -0,0 +1,44 @@
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
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'test_helper'
5
+
6
+ module Quonfig
7
+ class FixedSizeHashTest < Minitest::Test
8
+ def setup
9
+ @max_size = 3
10
+ @hash = FixedSizeHash.new(@max_size)
11
+ end
12
+
13
+ def test_acts_like_a_regular_hash_when_under_max_size
14
+ @hash[:a] = 1
15
+ @hash[:b] = 2
16
+
17
+ assert_equal 1, @hash[:a]
18
+ assert_equal 2, @hash[:b]
19
+ assert_equal 2, @hash.size
20
+ end
21
+
22
+ def test_enforces_max_size_by_evicting_first_added_item
23
+ @hash[:a] = 1
24
+ @hash[:b] = 2
25
+ @hash[:c] = 3
26
+ assert_equal @max_size, @hash.size
27
+
28
+ @hash[:d] = 4
29
+ assert_equal @max_size, @hash.size
30
+ assert_nil @hash[:a] # First item should be evicted
31
+ assert_equal 4, @hash[:d]
32
+ end
33
+
34
+ def test_updating_existing_key_does_not_trigger_eviction
35
+ @hash[:a] = 1
36
+ @hash[:b] = 2
37
+ @hash[:c] = 3
38
+
39
+ @hash[:b] = 'new value' # Update existing key
40
+
41
+ assert_equal @max_size, @hash.size
42
+ assert_equal 1, @hash[:a] # First item should still be present
43
+ assert_equal 'new value', @hash[:b]
44
+ assert_equal 3, @hash[:c]
45
+ end
46
+
47
+ def test_handles_nil_values
48
+ @hash[:a] = nil
49
+ @hash[:b] = 2
50
+ @hash[:c] = 3
51
+ @hash[:d] = 4
52
+
53
+ assert_nil @hash[:a] # First item should be evicted
54
+ assert_equal 4, @hash[:d]
55
+ end
56
+
57
+ def test_preserves_hash_methods
58
+ @hash[:a] = 1
59
+ @hash[:b] = 2
60
+
61
+ assert_equal [:a, :b], @hash.keys
62
+ assert_equal [1, 2], @hash.values
63
+ assert @hash.key?(:a)
64
+ refute @hash.key?(:z)
65
+ end
66
+
67
+ def test_handles_string_keys
68
+ @hash['a'] = 1
69
+ @hash['b'] = 2
70
+ @hash['c'] = 3
71
+ @hash['d'] = 4
72
+
73
+ assert_nil @hash['a'] # First item should be evicted
74
+ assert_equal 4, @hash['d']
75
+ end
76
+
77
+ def test_handles_object_keys
78
+ key1 = Object.new
79
+ key2 = Object.new
80
+ key3 = Object.new
81
+ key4 = Object.new
82
+
83
+ @hash[key1] = 1
84
+ @hash[key2] = 2
85
+ @hash[key3] = 3
86
+ @hash[key4] = 4
87
+
88
+ assert_nil @hash[key1] # First item should be evicted
89
+ assert_equal 4, @hash[key4]
90
+ end
91
+
92
+ def test_can_be_initialized_empty
93
+ assert_equal 0, @hash.size
94
+ end
95
+
96
+ def test_enumerable_methods
97
+ @hash[:a] = 1
98
+ @hash[:b] = 2
99
+
100
+ mapped = @hash.map { |k, v| [k, v * 2] }.to_h
101
+ assert_equal({ a: 2, b: 4 }, mapped)
102
+
103
+ filtered = @hash.select { |_, v| v > 1 }
104
+ assert_equal({ b: 2 }, filtered.to_h)
105
+ end
106
+
107
+ def test_clear_maintains_max_size_constraint
108
+ @hash[:a] = 1
109
+ @hash[:b] = 2
110
+ @hash.clear
111
+
112
+ assert_equal 0, @hash.size
113
+
114
+ # Should still enforce max size after clear
115
+ (@max_size + 1).times { |i| @hash[i] = i }
116
+ assert_equal @max_size, @hash.size
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/focus'
5
+ require 'minitest/reporters'
6
+ Minitest::Reporters.use! unless ENV['RM_INFO']
7
+
8
+ require 'quonfig'
9
+
10
+ Dir.glob(File.join(File.dirname(__FILE__), 'support', '**', '*.rb')).each do |file|
11
+ require file
12
+ end
13
+
14
+ Minitest::Test.class_eval do
15
+ include CommonHelpers
16
+ extend CommonHelpers
17
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'base64'
5
+ require 'json'
6
+
7
+ module Quonfig
8
+ class HttpConnectionTest < Minitest::Test
9
+ URI = 'https://primary.quonfig.com'
10
+ SDK_KEY = 'abc.def.123'
11
+
12
+ def test_uses_basic_auth_with_username_1
13
+ conn = HttpConnection.new(URI, SDK_KEY).connection
14
+ expected = 'Basic ' + Base64.strict_encode64("1:#{SDK_KEY}")
15
+ assert_equal expected, conn.headers['Authorization']
16
+ end
17
+
18
+ def test_json_content_type_and_accept_headers
19
+ conn = HttpConnection.new(URI, SDK_KEY).connection
20
+ assert_equal 'application/json', conn.headers['Content-Type']
21
+ assert_equal 'application/json', conn.headers['Accept']
22
+ end
23
+
24
+ def test_x_quonfig_sdk_version_header
25
+ conn = HttpConnection.new(URI, SDK_KEY).connection
26
+ assert_equal 'ruby-0.1.0', conn.headers['X-Quonfig-SDK-Version']
27
+ end
28
+
29
+ def test_no_protobuf_content_type
30
+ conn = HttpConnection.new(URI, SDK_KEY).connection
31
+ refute_equal 'application/x-protobuf', conn.headers['Content-Type']
32
+ refute_equal 'application/x-protobuf', conn.headers['Accept']
33
+ end
34
+
35
+ def test_post_serializes_body_as_json
36
+ stubs = Faraday::Adapter::Test::Stubs.new
37
+ captured_body = nil
38
+ captured_content_type = nil
39
+ stubs.post('/telemetry') do |env|
40
+ captured_body = env.body
41
+ captured_content_type = env.request_headers['Content-Type']
42
+ [200, {}, '']
43
+ end
44
+
45
+ http = HttpConnection.new(URI, SDK_KEY)
46
+ http.define_singleton_method(:connection) do |headers = {}|
47
+ Faraday.new(URI) do |conn|
48
+ conn.headers.merge!(HttpConnection::JSON_HEADERS.merge(headers))
49
+ conn.adapter :test, stubs
50
+ end
51
+ end
52
+
53
+ body = { hello: 'world', n: 3 }
54
+ http.post('/telemetry', body)
55
+
56
+ assert_equal 'application/json', captured_content_type
57
+ assert_equal body.to_json, captured_body
58
+ stubs.verify_stubbed_calls
59
+ end
60
+ end
61
+
62
+ class OptionsDefaultApiUrlsTest < Minitest::Test
63
+ def test_default_api_urls_use_quonfig_domain
64
+ assert_includes Options::DEFAULT_API_URLS, 'https://primary.quonfig.com'
65
+ refute(Options::DEFAULT_API_URLS.any? { |s| s.include?('reforge.com') })
66
+ end
67
+
68
+ def test_telemetry_destination_honors_quonfig_telemetry_url_env
69
+ with_env('QUONFIG_TELEMETRY_URL', 'https://override-telemetry.example.com') do
70
+ assert_equal 'https://override-telemetry.example.com',
71
+ Options.new.telemetry_destination
72
+ end
73
+ end
74
+
75
+ def test_telemetry_destination_default
76
+ assert_equal 'https://telemetry.quonfig.com', Options.new.telemetry_destination
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ class TestInternalLogger < Minitest::Test
6
+
7
+ def teardown
8
+ # using_quonfig_log_filter! mutates the shared @@instances list — restore
9
+ # the default :warn level so it doesn't bleed into other tests' $logs.
10
+ Quonfig::InternalLogger.class_variable_get(:@@instances).each do |logger|
11
+ logger.level = :warn
12
+ end
13
+ super
14
+ end
15
+
16
+ def test_levels
17
+ logger_a = Quonfig::InternalLogger.new(A)
18
+ logger_b = Quonfig::InternalLogger.new(B)
19
+
20
+ assert_equal :warn, logger_a.level
21
+ assert_equal :warn, logger_b.level
22
+
23
+ Quonfig::InternalLogger.using_quonfig_log_filter!
24
+ assert_equal :trace, logger_a.level
25
+ assert_equal :trace, logger_b.level
26
+ end
27
+
28
+ end
29
+
30
+ class A
31
+ end
32
+
33
+ class B
34
+ end