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.
- checksums.yaml +7 -0
- data/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- 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
|
data/test/test_helper.rb
ADDED
|
@@ -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
|