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
data/test/test_client.rb
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Quonfig::Client wires the JSON stack: ConfigStore + Evaluator + Resolver
|
|
6
|
+
# (introduced in qfg-dk6.4-9). These tests drive Client through an injected
|
|
7
|
+
# ConfigStore so they never touch the network or the filesystem. The legacy
|
|
8
|
+
# protobuf ConfigClient/ConfigResolver path was removed in qfg-dk6.32.
|
|
9
|
+
class TestClient < Minitest::Test
|
|
10
|
+
CONFIG_KEY = 'my.flag'
|
|
11
|
+
|
|
12
|
+
# ---- Test fixtures -----------------------------------------------------
|
|
13
|
+
|
|
14
|
+
# Plain ConfigResponse-shaped hash (mirrors what
|
|
15
|
+
# Quonfig::Datadir.to_config_response and IntegrationTestHelpers emit).
|
|
16
|
+
def make_config(key:, value:, type: 'string', criteria: nil)
|
|
17
|
+
{
|
|
18
|
+
'id' => '1',
|
|
19
|
+
'key' => key,
|
|
20
|
+
'type' => 'config',
|
|
21
|
+
'valueType' => type,
|
|
22
|
+
'sendToClientSdk' => false,
|
|
23
|
+
'default' => {
|
|
24
|
+
'rules' => [
|
|
25
|
+
{
|
|
26
|
+
'criteria' => criteria || [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
27
|
+
'value' => { 'type' => type, 'value' => value }
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
'environment' => nil
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def store_with(*configs)
|
|
36
|
+
store = Quonfig::ConfigStore.new
|
|
37
|
+
configs.each { |c| store.set(c['key'], c) }
|
|
38
|
+
store
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def client_with(store, **options)
|
|
42
|
+
Quonfig::Client.new(Quonfig::Options.new(**options), store: store)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ---- Construction ------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def test_constructor_accepts_options_object
|
|
48
|
+
client = client_with(Quonfig::ConfigStore.new)
|
|
49
|
+
assert_kind_of Quonfig::Options, client.options
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_constructor_wires_resolver_and_evaluator
|
|
53
|
+
store = Quonfig::ConfigStore.new
|
|
54
|
+
client = Quonfig::Client.new(Quonfig::Options.new, store: store)
|
|
55
|
+
|
|
56
|
+
assert_kind_of Quonfig::Resolver, client.resolver
|
|
57
|
+
assert_kind_of Quonfig::Evaluator, client.evaluator
|
|
58
|
+
assert_same store, client.store,
|
|
59
|
+
'Client must use the injected ConfigStore instance'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def test_instance_hash_is_unique_per_client
|
|
63
|
+
a = client_with(Quonfig::ConfigStore.new)
|
|
64
|
+
b = client_with(Quonfig::ConfigStore.new)
|
|
65
|
+
refute_equal a.instance_hash, b.instance_hash
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ---- get returns coerced JSON values, not PrefabProto ------------------
|
|
69
|
+
|
|
70
|
+
def test_get_returns_string_value
|
|
71
|
+
store = store_with(make_config(key: CONFIG_KEY, value: 'hello'))
|
|
72
|
+
assert_equal 'hello', client_with(store).get(CONFIG_KEY)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_get_returns_int_value
|
|
76
|
+
store = store_with(make_config(key: CONFIG_KEY, value: 42, type: 'int'))
|
|
77
|
+
assert_equal 42, client_with(store).get(CONFIG_KEY)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_get_returns_bool_value
|
|
81
|
+
store = store_with(make_config(key: CONFIG_KEY, value: true, type: 'bool'))
|
|
82
|
+
assert_equal true, client_with(store).get(CONFIG_KEY)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_get_returned_value_is_not_a_prefab_proto
|
|
86
|
+
store = store_with(make_config(key: CONFIG_KEY, value: 'hello'))
|
|
87
|
+
value = client_with(store).get(CONFIG_KEY)
|
|
88
|
+
|
|
89
|
+
refute value.respond_to?(:string_list),
|
|
90
|
+
'Client#get must return a plain Ruby value, not a PrefabProto::ConfigValue'
|
|
91
|
+
refute value.is_a?(Hash),
|
|
92
|
+
'Client#get must unwrap to the coerced Ruby value, not the JSON Value hash'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ---- Missing key handling ---------------------------------------------
|
|
96
|
+
|
|
97
|
+
def test_get_returns_explicit_default_when_key_missing
|
|
98
|
+
store = Quonfig::ConfigStore.new
|
|
99
|
+
assert_equal 'fallback', client_with(store).get('nope', 'fallback')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_get_raises_missing_default_error_by_default
|
|
103
|
+
store = Quonfig::ConfigStore.new
|
|
104
|
+
assert_raises(Quonfig::Errors::MissingDefaultError) do
|
|
105
|
+
client_with(store).get('nope')
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def test_get_returns_nil_when_on_no_default_is_return_nil
|
|
110
|
+
store = Quonfig::ConfigStore.new
|
|
111
|
+
client = client_with(store, on_no_default: Quonfig::Options::ON_NO_DEFAULT::RETURN_NIL)
|
|
112
|
+
assert_nil client.get('nope')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ---- enabled? --------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def test_enabled_returns_true_when_value_is_true
|
|
118
|
+
store = store_with(make_config(key: CONFIG_KEY, value: true, type: 'bool'))
|
|
119
|
+
assert client_with(store).enabled?(CONFIG_KEY)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_enabled_returns_false_when_value_is_false
|
|
123
|
+
store = store_with(make_config(key: CONFIG_KEY, value: false, type: 'bool'))
|
|
124
|
+
refute client_with(store).enabled?(CONFIG_KEY)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_enabled_returns_false_for_missing_key
|
|
128
|
+
store = Quonfig::ConfigStore.new
|
|
129
|
+
refute client_with(store).enabled?('nope')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- defined? + keys --------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def test_defined_returns_true_for_known_key
|
|
135
|
+
store = store_with(make_config(key: CONFIG_KEY, value: 'x'))
|
|
136
|
+
assert client_with(store).defined?(CONFIG_KEY)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def test_defined_returns_false_for_unknown_key
|
|
140
|
+
store = store_with(make_config(key: CONFIG_KEY, value: 'x'))
|
|
141
|
+
refute client_with(store).defined?('absent')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def test_keys_returns_store_keys
|
|
145
|
+
store = store_with(
|
|
146
|
+
make_config(key: 'a', value: '1'),
|
|
147
|
+
make_config(key: 'b', value: '2')
|
|
148
|
+
)
|
|
149
|
+
assert_equal %w[a b].sort, client_with(store).keys.sort
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ---- Context: jit context is plain Hash, not PrefabProto::Context ----
|
|
153
|
+
|
|
154
|
+
def test_get_accepts_jit_context_as_plain_hash
|
|
155
|
+
cfg = make_config(
|
|
156
|
+
key: CONFIG_KEY,
|
|
157
|
+
value: 'matched',
|
|
158
|
+
criteria: [{
|
|
159
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
160
|
+
'propertyName' => 'user.role',
|
|
161
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['admin'] }
|
|
162
|
+
}]
|
|
163
|
+
)
|
|
164
|
+
store = store_with(cfg)
|
|
165
|
+
|
|
166
|
+
result = client_with(store).get(CONFIG_KEY, 'fallback', user: { 'role' => 'admin' })
|
|
167
|
+
|
|
168
|
+
assert_equal 'matched', result
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def test_with_context_returns_bound_client
|
|
172
|
+
bound = client_with(Quonfig::ConfigStore.new).with_context(user: { 'key' => '1' })
|
|
173
|
+
assert_kind_of Quonfig::BoundClient, bound
|
|
174
|
+
assert_equal({ user: { 'key' => '1' } }, bound.context)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_in_context_yields_bound_client_when_block_given
|
|
178
|
+
yielded = nil
|
|
179
|
+
client_with(Quonfig::ConfigStore.new).in_context(user: { 'key' => '1' }) do |bound|
|
|
180
|
+
yielded = bound
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
assert_kind_of Quonfig::BoundClient, yielded
|
|
184
|
+
assert_equal({ user: { 'key' => '1' } }, yielded.context)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def test_global_context_is_merged_into_jit_context
|
|
188
|
+
cfg = make_config(
|
|
189
|
+
key: CONFIG_KEY,
|
|
190
|
+
value: 'admin-value',
|
|
191
|
+
criteria: [{
|
|
192
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
193
|
+
'propertyName' => 'user.role',
|
|
194
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['admin'] }
|
|
195
|
+
}]
|
|
196
|
+
)
|
|
197
|
+
store = store_with(cfg)
|
|
198
|
+
client = Quonfig::Client.new(
|
|
199
|
+
Quonfig::Options.new(global_context: { user: { 'role' => 'admin' } }),
|
|
200
|
+
store: store
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
assert_equal 'admin-value', client.get(CONFIG_KEY, 'fallback')
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def test_jit_context_overrides_global_context_at_the_property_level
|
|
207
|
+
cfg = make_config(
|
|
208
|
+
key: CONFIG_KEY,
|
|
209
|
+
value: 'jit-value',
|
|
210
|
+
criteria: [{
|
|
211
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
212
|
+
'propertyName' => 'user.role',
|
|
213
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['user'] }
|
|
214
|
+
}]
|
|
215
|
+
)
|
|
216
|
+
store = store_with(cfg)
|
|
217
|
+
client = Quonfig::Client.new(
|
|
218
|
+
Quonfig::Options.new(global_context: { user: { 'role' => 'admin' } }),
|
|
219
|
+
store: store
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# jit overrides global for this single property; keys unique to global preserved
|
|
223
|
+
assert_equal 'jit-value', client.get(CONFIG_KEY, 'fallback', user: { 'role' => 'user' })
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_normalize_context_rejects_non_hash_jit_context
|
|
227
|
+
store = Quonfig::ConfigStore.new
|
|
228
|
+
assert_raises(ArgumentError) do
|
|
229
|
+
client_with(store).get('nope', 'fallback', 'not-a-hash')
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# ---- Misc -------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def test_stop_is_a_noop
|
|
236
|
+
client_with(Quonfig::ConfigStore.new).stop
|
|
237
|
+
pass
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def test_inspect_includes_environment
|
|
241
|
+
client = client_with(Quonfig::ConfigStore.new, environment: 'Production')
|
|
242
|
+
assert_match(/environment="Production"/, client.inspect)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_no_prefab_proto_in_lib_quonfig_source
|
|
246
|
+
# qfg-dk6.32: scrub PrefabProto from the runtime lib path.
|
|
247
|
+
lib_dir = File.expand_path('../lib/quonfig', __dir__)
|
|
248
|
+
offenders = Dir.glob(File.join(lib_dir, '**/*.rb')).select do |path|
|
|
249
|
+
File.read(path).match?(/PrefabProto/)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
assert_empty offenders,
|
|
253
|
+
"lib/quonfig still references PrefabProto:\n#{offenders.join("\n")}"
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'ostruct'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
class TestConfigLoader < Minitest::Test
|
|
8
|
+
def setup
|
|
9
|
+
super
|
|
10
|
+
options = Quonfig::Options.new(
|
|
11
|
+
sdk_key: '1-test-sdk-key',
|
|
12
|
+
api_urls: ['https://primary.example.test']
|
|
13
|
+
)
|
|
14
|
+
@loader = Quonfig::ConfigLoader.new(MockBaseClient.new(options))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_fetch_200_populates_configs_and_stores_etag_then_304_is_a_noop
|
|
18
|
+
body = JSON.generate(
|
|
19
|
+
'configs' => [
|
|
20
|
+
{ 'key' => 'my.flag', 'type' => 'config', 'valueType' => 'bool',
|
|
21
|
+
'default' => { 'rules' => [] } }
|
|
22
|
+
],
|
|
23
|
+
'meta' => { 'version' => 'v1', 'environment' => 'production' }
|
|
24
|
+
)
|
|
25
|
+
ok_response = Faraday::Response.new(
|
|
26
|
+
status: 200, body: body,
|
|
27
|
+
response_headers: { 'ETag' => 'W/"etag-one"' }
|
|
28
|
+
)
|
|
29
|
+
not_modified = Faraday::Response.new(
|
|
30
|
+
status: 304, body: '', response_headers: {}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
observed_headers = []
|
|
34
|
+
|
|
35
|
+
http_conn = Minitest::Mock.new
|
|
36
|
+
http_conn.expect(:get, ok_response) do |path, headers|
|
|
37
|
+
observed_headers << headers.dup
|
|
38
|
+
path == '/api/v2/configs' && !headers.key?('If-None-Match')
|
|
39
|
+
end
|
|
40
|
+
http_conn.expect(:get, not_modified) do |path, headers|
|
|
41
|
+
observed_headers << headers.dup
|
|
42
|
+
path == '/api/v2/configs' && headers['If-None-Match'] == 'W/"etag-one"'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Quonfig::HttpConnection.stub :new, ->(_uri, _key) { http_conn } do
|
|
46
|
+
assert_equal :updated, @loader.fetch!
|
|
47
|
+
assert_equal :not_modified, @loader.fetch!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
assert_equal 'W/"etag-one"', @loader.etag
|
|
51
|
+
calc = @loader.calc_config
|
|
52
|
+
assert calc.key?('my.flag'), "expected 'my.flag' to be loaded"
|
|
53
|
+
assert_equal 'W/"etag-one"', observed_headers[1]['If-None-Match']
|
|
54
|
+
http_conn.verify
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def test_set_and_rm_preserved
|
|
58
|
+
config = OpenStruct.new(key: 'x', rows: [1])
|
|
59
|
+
@loader.set(config, :test)
|
|
60
|
+
assert @loader.calc_config.key?('x')
|
|
61
|
+
|
|
62
|
+
@loader.rm('x')
|
|
63
|
+
refute @loader.calc_config.key?('x')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test_no_highwater_mark_attribute
|
|
67
|
+
refute @loader.respond_to?(:highwater_mark),
|
|
68
|
+
'highwater_mark should be removed from ConfigLoader'
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestContext < Minitest::Test
|
|
6
|
+
EXAMPLE_PROPERTIES = {
|
|
7
|
+
user: { key: 'some-user-key', name: 'Ted' },
|
|
8
|
+
team: { key: 'abc', plan: 'pro' }
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
def test_initialize_with_empty_context
|
|
12
|
+
context = Quonfig::Context.new({})
|
|
13
|
+
assert_empty context.contexts
|
|
14
|
+
assert context.blank?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_initialize_with_hash
|
|
18
|
+
context = Quonfig::Context.new(test: { foo: 'bar' })
|
|
19
|
+
assert_equal 1, context.contexts.size
|
|
20
|
+
assert_equal 'bar', context.get('test.foo')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_initialize_with_multiple_hashes
|
|
24
|
+
context = Quonfig::Context.new(test: { foo: 'bar' }, other: { foo: 'baz' })
|
|
25
|
+
assert_equal 2, context.contexts.size
|
|
26
|
+
assert_equal 'bar', context.get('test.foo')
|
|
27
|
+
assert_equal 'baz', context.get('other.foo')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_initialize_with_invalid_argument
|
|
31
|
+
assert_raises(ArgumentError) { Quonfig::Context.new([]) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_setting
|
|
35
|
+
context = Quonfig::Context.new({})
|
|
36
|
+
context.set('user', { key: 'value' })
|
|
37
|
+
context.set(:other, { key: 'different', something: 'other' })
|
|
38
|
+
|
|
39
|
+
assert_equal(
|
|
40
|
+
stringify(user: { key: 'value' }, other: { key: 'different', something: 'other' }),
|
|
41
|
+
context.to_h
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_getting
|
|
46
|
+
context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
|
|
47
|
+
assert_equal('some-user-key', context.get('user.key'))
|
|
48
|
+
assert_equal('pro', context.get('team.plan'))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_dot_notation_getting
|
|
52
|
+
context = Quonfig::Context.new('user' => { 'key' => 'value' })
|
|
53
|
+
assert_equal('value', context.get('user.key'))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_dot_notation_getting_with_symbols
|
|
57
|
+
context = Quonfig::Context.new(user: { key: 'value' })
|
|
58
|
+
assert_equal('value', context.get('user.key'))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_get_returns_nil_for_missing_property
|
|
62
|
+
context = Quonfig::Context.new(user: { key: 'value' })
|
|
63
|
+
assert_nil context.get('user.missing')
|
|
64
|
+
assert_nil context.get('absent.key')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_clear
|
|
68
|
+
context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
|
|
69
|
+
context.clear
|
|
70
|
+
|
|
71
|
+
assert_empty context.to_h
|
|
72
|
+
assert context.blank?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_to_h_stringifies_keys
|
|
76
|
+
context = Quonfig::Context.new(EXAMPLE_PROPERTIES)
|
|
77
|
+
assert_equal stringify(EXAMPLE_PROPERTIES), context.to_h
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_legacy_flat_hash_shorthand_promotes_to_blank_named_context
|
|
81
|
+
# Pre-named-contexts callers passed a flat Hash. The constructor still
|
|
82
|
+
# accepts that shape and stuffs it under the empty-string named context.
|
|
83
|
+
context = Quonfig::Context.new('foo' => 'bar')
|
|
84
|
+
assert_equal 'bar', context.get('.foo')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def test_grouped_key_combines_named_contexts_by_key
|
|
88
|
+
context = Quonfig::Context.new(
|
|
89
|
+
user: { key: 'u1' },
|
|
90
|
+
team: { key: 't1' }
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
assert_equal 'team:t1|user:u1', context.grouped_key
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_named_context_lookup_returns_namedcontext
|
|
97
|
+
context = Quonfig::Context.new(user: { key: 'u1', name: 'Ted' })
|
|
98
|
+
user = context.context('user')
|
|
99
|
+
|
|
100
|
+
assert_kind_of Quonfig::Context::NamedContext, user
|
|
101
|
+
assert_equal 'user', user.name
|
|
102
|
+
assert_equal({ 'key' => 'u1', 'name' => 'Ted' }, user.to_h)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_named_context_lookup_for_missing_returns_empty_namedcontext
|
|
106
|
+
context = Quonfig::Context.new(user: { key: 'u1' })
|
|
107
|
+
missing = context.context('absent')
|
|
108
|
+
|
|
109
|
+
assert_kind_of Quonfig::Context::NamedContext, missing
|
|
110
|
+
assert_equal 'absent', missing.name
|
|
111
|
+
assert_empty missing.to_h
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_comparable
|
|
115
|
+
a = Quonfig::Context.new(user: { key: 'u1' })
|
|
116
|
+
b = Quonfig::Context.new(user: { key: 'u1' })
|
|
117
|
+
c = Quonfig::Context.new(user: { key: 'u2' })
|
|
118
|
+
|
|
119
|
+
assert_equal a, b
|
|
120
|
+
refute_equal a, c
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def stringify(hash)
|
|
126
|
+
hash.map { |k, v| [k.to_s, stringify_keys(v)] }.to_h
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def stringify_keys(value)
|
|
130
|
+
if value.is_a?(Hash)
|
|
131
|
+
value.transform_keys(&:to_s)
|
|
132
|
+
else
|
|
133
|
+
value
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
# qfg-dk6.18 — Quonfig::Datadir loads JSON config files from a workspace
|
|
9
|
+
# directory and produces a ConfigEnvelope (and a populated ConfigStore) for
|
|
10
|
+
# offline / datadir mode. Mirrors sdk-node/src/datadir.ts.
|
|
11
|
+
class TestDatadir < Minitest::Test
|
|
12
|
+
CONFIG_SUBDIRS = %w[configs feature-flags segments schemas log-levels].freeze
|
|
13
|
+
|
|
14
|
+
def setup
|
|
15
|
+
@tmpdir = Dir.mktmpdir('quonfig-datadir-test')
|
|
16
|
+
CONFIG_SUBDIRS.each { |sub| FileUtils.mkdir_p(File.join(@tmpdir, sub)) }
|
|
17
|
+
File.write(File.join(@tmpdir, 'quonfig.json'), JSON.generate({ environments: %w[Production Staging] }))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def teardown
|
|
21
|
+
FileUtils.remove_entry(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
|
|
22
|
+
ENV.delete('QUONFIG_ENVIRONMENT')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def write_config(subdir, filename, body)
|
|
26
|
+
File.write(File.join(@tmpdir, subdir, filename), JSON.generate(body))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def sample_config(key, environment_id: 'Production')
|
|
30
|
+
{
|
|
31
|
+
'id' => '111',
|
|
32
|
+
'key' => key,
|
|
33
|
+
'type' => 'config',
|
|
34
|
+
'valueType' => 'string',
|
|
35
|
+
'sendToClientSdk' => false,
|
|
36
|
+
'default' => { 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'string', 'value' => 'hello' } }] },
|
|
37
|
+
'environments' => [{ 'id' => environment_id, 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'string', 'value' => 'env-hello' } }] }]
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sample_flag(key)
|
|
42
|
+
{
|
|
43
|
+
'id' => '222',
|
|
44
|
+
'key' => key,
|
|
45
|
+
'type' => 'feature_flag',
|
|
46
|
+
'valueType' => 'bool',
|
|
47
|
+
'default' => { 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }] },
|
|
48
|
+
'environments' => []
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_load_envelope_returns_config_envelope
|
|
53
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
54
|
+
|
|
55
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
56
|
+
|
|
57
|
+
assert_instance_of Quonfig::ConfigEnvelope, envelope
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_load_envelope_reads_configs_from_each_subdir
|
|
61
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
62
|
+
write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
|
|
63
|
+
write_config('log-levels', 'c.log.json', sample_config('c.log'))
|
|
64
|
+
write_config('segments', 'd.segment.json', sample_config('d.segment'))
|
|
65
|
+
|
|
66
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
67
|
+
|
|
68
|
+
keys = envelope.configs.map { |c| c['key'] }
|
|
69
|
+
assert_equal %w[a.config c.log d.segment b.flag].sort, keys.sort
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_load_envelope_skips_non_json_and_missing_subdirs
|
|
73
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
74
|
+
File.write(File.join(@tmpdir, 'configs', 'README.md'), '# ignore me')
|
|
75
|
+
FileUtils.rm_rf(File.join(@tmpdir, 'segments'))
|
|
76
|
+
|
|
77
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
78
|
+
|
|
79
|
+
keys = envelope.configs.map { |c| c['key'] }
|
|
80
|
+
assert_equal ['a.config'], keys
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def test_load_envelope_picks_environment_block_by_id
|
|
84
|
+
write_config('configs', 'a.config.json', sample_config('a.config', environment_id: 'Production'))
|
|
85
|
+
|
|
86
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
87
|
+
|
|
88
|
+
cfg = envelope.configs.first
|
|
89
|
+
refute_nil cfg['environment'], 'expected environment field to be populated'
|
|
90
|
+
assert_equal 'Production', cfg['environment']['id']
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def test_load_envelope_environment_field_nil_when_no_match
|
|
94
|
+
write_config('configs', 'a.config.json', sample_config('a.config', environment_id: 'Other'))
|
|
95
|
+
|
|
96
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
97
|
+
|
|
98
|
+
cfg = envelope.configs.first
|
|
99
|
+
assert_nil cfg['environment']
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_load_envelope_send_to_client_sdk_true_for_feature_flag
|
|
103
|
+
write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
|
|
104
|
+
|
|
105
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
106
|
+
|
|
107
|
+
cfg = envelope.configs.first
|
|
108
|
+
assert_equal true, cfg['sendToClientSdk'], 'feature_flag must always sendToClientSdk=true'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_load_envelope_default_rules_when_missing
|
|
112
|
+
raw = sample_config('a.config')
|
|
113
|
+
raw.delete('default')
|
|
114
|
+
write_config('configs', 'a.config.json', raw)
|
|
115
|
+
|
|
116
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
117
|
+
|
|
118
|
+
cfg = envelope.configs.first
|
|
119
|
+
assert_equal({ 'rules' => [] }, cfg['default'])
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_load_envelope_meta_includes_version_and_environment
|
|
123
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
124
|
+
|
|
125
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
126
|
+
|
|
127
|
+
assert_equal "datadir:#{@tmpdir}", envelope.meta['version']
|
|
128
|
+
assert_equal 'Production', envelope.meta['environment']
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def test_resolve_environment_from_env_var
|
|
132
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
133
|
+
ENV['QUONFIG_ENVIRONMENT'] = 'Production'
|
|
134
|
+
|
|
135
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, nil)
|
|
136
|
+
|
|
137
|
+
assert_equal 'Production', envelope.meta['environment']
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def test_constructor_environment_supersedes_env_var
|
|
141
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
142
|
+
ENV['QUONFIG_ENVIRONMENT'] = 'Staging'
|
|
143
|
+
|
|
144
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
|
|
145
|
+
|
|
146
|
+
assert_equal 'Production', envelope.meta['environment']
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def test_raises_when_no_environment
|
|
150
|
+
err = assert_raises(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, nil) }
|
|
151
|
+
assert_match(/Environment required for datadir mode/, err.message)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def test_raises_when_quonfig_json_missing
|
|
155
|
+
File.delete(File.join(@tmpdir, 'quonfig.json'))
|
|
156
|
+
|
|
157
|
+
err = assert_raises(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, 'Production') }
|
|
158
|
+
assert_match(/missing quonfig\.json/, err.message)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_raises_when_environment_not_in_workspace
|
|
162
|
+
err = assert_raises(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, 'NotAnEnv') }
|
|
163
|
+
assert_match(/Environment "NotAnEnv" not found/, err.message)
|
|
164
|
+
assert_match(/Production/, err.message)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def test_allows_any_environment_when_quonfig_json_environments_empty
|
|
168
|
+
File.write(File.join(@tmpdir, 'quonfig.json'), JSON.generate({ environments: [] }))
|
|
169
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
170
|
+
|
|
171
|
+
envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'AnythingGoes')
|
|
172
|
+
|
|
173
|
+
assert_equal 'AnythingGoes', envelope.meta['environment']
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def test_load_store_returns_populated_config_store
|
|
177
|
+
write_config('configs', 'a.config.json', sample_config('a.config'))
|
|
178
|
+
write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
|
|
179
|
+
|
|
180
|
+
store = Quonfig::Datadir.load_store(@tmpdir, 'Production')
|
|
181
|
+
|
|
182
|
+
assert_instance_of Quonfig::ConfigStore, store
|
|
183
|
+
assert_equal %w[a.config b.flag].sort, store.keys.sort
|
|
184
|
+
assert_equal 'a.config', store.get('a.config')['key']
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Verification against the real integration-test-data fixture (path-based).
|
|
188
|
+
# Skips when the sibling repo is not present (e.g. limited CI checkouts).
|
|
189
|
+
def test_load_store_with_integration_test_data_fixture
|
|
190
|
+
fixture = File.expand_path('../../integration-test-data/data/integration-tests', __dir__)
|
|
191
|
+
skip "integration-test-data not present at #{fixture}" unless Dir.exist?(fixture)
|
|
192
|
+
|
|
193
|
+
store = Quonfig::Datadir.load_store(fixture, 'Production')
|
|
194
|
+
|
|
195
|
+
refute_empty store.keys, 'expected at least one config key from integration-test-data'
|
|
196
|
+
assert(store.keys.any? { |k| k.start_with?('a.') } || store.keys.include?('already.in.use'),
|
|
197
|
+
"expected to find familiar fixture keys; got: #{store.keys.first(5).inspect}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class DurationTest < Minitest::Test
|
|
6
|
+
MINUTES_IN_SECONDS = 60
|
|
7
|
+
HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
|
|
8
|
+
DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
|
|
9
|
+
|
|
10
|
+
TESTS = [
|
|
11
|
+
['PT0M0S', 0],
|
|
12
|
+
['PT6M', 6 * MINUTES_IN_SECONDS],
|
|
13
|
+
['PT90S', 90],
|
|
14
|
+
['P1D', DAYS_IN_SECONDS],
|
|
15
|
+
['PT1.5M', 1.5 * MINUTES_IN_SECONDS],
|
|
16
|
+
['P0.75D', 0.75 * DAYS_IN_SECONDS],
|
|
17
|
+
['PT1M90.3S', MINUTES_IN_SECONDS + 90.3],
|
|
18
|
+
['PT1H', HOURS_IN_SECONDS],
|
|
19
|
+
['PT1.3H', 1.3 * HOURS_IN_SECONDS],
|
|
20
|
+
['P1.5DT1.5M', 1.5 * DAYS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5],
|
|
21
|
+
['P1.5DT1.5H1.5M', 1.5 * DAYS_IN_SECONDS + 1.5 * HOURS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5],
|
|
22
|
+
['P1.5DT1.5H1.5M3.5S', 1.5 * DAYS_IN_SECONDS + 1.5 * HOURS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5 + 3.5],
|
|
23
|
+
['P1DT2H3M4S', DAYS_IN_SECONDS + 2 * HOURS_IN_SECONDS + 3 * MINUTES_IN_SECONDS + 4],
|
|
24
|
+
['PT1H30M', HOURS_IN_SECONDS + 30 * MINUTES_IN_SECONDS],
|
|
25
|
+
['P0.5DT0.25H', 0.5 * DAYS_IN_SECONDS + 0.25 * HOURS_IN_SECONDS],
|
|
26
|
+
['PT15M30S', 15 * MINUTES_IN_SECONDS + 30],
|
|
27
|
+
['PT0.000347222H', 0.000347222 * HOURS_IN_SECONDS],
|
|
28
|
+
['PT23H59M59S', 23 * HOURS_IN_SECONDS + 59 * MINUTES_IN_SECONDS + 59],
|
|
29
|
+
['P0.25DT3.75H', 0.25 * DAYS_IN_SECONDS + 3.75 * HOURS_IN_SECONDS],
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def test_parsing
|
|
33
|
+
TESTS.each do |test|
|
|
34
|
+
assert_equal test[1], Quonfig::Duration.parse(test[0]), "Failed parsing #{test[0]}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|