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,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