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,108 @@
1
+ require 'test_helper'
2
+ class TestSemanticVersion < Minitest::Test
3
+ def test_parse_valid_version
4
+ version = SemanticVersion.parse('1.2.3')
5
+ assert_equal 1, version.major
6
+ assert_equal 2, version.minor
7
+ assert_equal 3, version.patch
8
+ assert_nil version.prerelease
9
+ assert_nil version.build_metadata
10
+ end
11
+
12
+ def test_parse_version_with_prerelease
13
+ version = SemanticVersion.parse('1.2.3-alpha.1')
14
+ assert_equal 1, version.major
15
+ assert_equal 2, version.minor
16
+ assert_equal 3, version.patch
17
+ assert_equal 'alpha.1', version.prerelease
18
+ assert_nil version.build_metadata
19
+ end
20
+
21
+ def test_parse_version_with_build_metadata
22
+ version = SemanticVersion.parse('1.2.3+build.123')
23
+ assert_equal 1, version.major
24
+ assert_equal 2, version.minor
25
+ assert_equal 3, version.patch
26
+ assert_nil version.prerelease
27
+ assert_equal 'build.123', version.build_metadata
28
+ end
29
+
30
+ def test_parse_full_version
31
+ version = SemanticVersion.parse('1.2.3-alpha.1+build.123')
32
+ assert_equal 1, version.major
33
+ assert_equal 2, version.minor
34
+ assert_equal 3, version.patch
35
+ assert_equal 'alpha.1', version.prerelease
36
+ assert_equal 'build.123', version.build_metadata
37
+ end
38
+
39
+ def test_parse_invalid_version
40
+ assert_raises(ArgumentError) { SemanticVersion.parse('invalid') }
41
+ assert_raises(ArgumentError) { SemanticVersion.parse('1.2') }
42
+ assert_raises(ArgumentError) { SemanticVersion.parse('1.2.3.4') }
43
+ assert_raises(ArgumentError) { SemanticVersion.parse('') }
44
+ end
45
+
46
+ def test_parse_quietly
47
+ assert_nil SemanticVersion.parse_quietly('invalid')
48
+ refute_nil SemanticVersion.parse_quietly('1.2.3')
49
+ end
50
+
51
+ def test_to_string
52
+ assert_equal '1.2.3', SemanticVersion.parse('1.2.3').to_s
53
+ assert_equal '1.2.3-alpha.1', SemanticVersion.parse('1.2.3-alpha.1').to_s
54
+ assert_equal '1.2.3+build.123', SemanticVersion.parse('1.2.3+build.123').to_s
55
+ assert_equal '1.2.3-alpha.1+build.123', SemanticVersion.parse('1.2.3-alpha.1+build.123').to_s
56
+ end
57
+
58
+ def test_equality
59
+ v1 = SemanticVersion.parse('1.2.3')
60
+ v2 = SemanticVersion.parse('1.2.3')
61
+ v3 = SemanticVersion.parse('1.2.4')
62
+ v4 = SemanticVersion.parse('1.2.3-alpha')
63
+ v5 = SemanticVersion.parse('1.2.3+build.123')
64
+
65
+ assert_equal v1, v2
66
+ refute_equal v1, v3
67
+ refute_equal v1, v4
68
+ assert_equal v1, v5 # build metadata is ignored in equality
69
+ end
70
+
71
+ def test_comparison
72
+ versions = [
73
+ '1.0.0-alpha',
74
+ '1.0.0-alpha.1',
75
+ '1.0.0-beta.2',
76
+ '1.0.0-beta.11',
77
+ '1.0.0-rc.1',
78
+ '1.0.0',
79
+ '2.0.0',
80
+ '2.1.0',
81
+ '2.1.1'
82
+ ].map { |v| SemanticVersion.parse(v) }
83
+
84
+ # Test that each version is less than the next version
85
+ (versions.length - 1).times do |i|
86
+ assert versions[i] < versions[i + 1], "Expected #{versions[i]} < #{versions[i + 1]}"
87
+ end
88
+ end
89
+
90
+ def test_prerelease_comparison
91
+ # Test specific prerelease comparison cases
92
+ cases = [
93
+ ['1.0.0-alpha', '1.0.0-alpha.1', -1],
94
+ ['1.0.0-alpha.1', '1.0.0-alpha.beta', -1],
95
+ ['1.0.0-alpha.beta', '1.0.0-beta', -1],
96
+ ['1.0.0-beta', '1.0.0-beta.2', -1],
97
+ ['1.0.0-beta.2', '1.0.0-beta.11', -1],
98
+ ['1.0.0-beta.11', '1.0.0-rc.1', -1],
99
+ ['1.0.0-rc.1', '1.0.0', -1]
100
+ ]
101
+
102
+ cases.each do |v1_str, v2_str, expected|
103
+ v1 = SemanticVersion.parse(v1_str)
104
+ v2 = SemanticVersion.parse(v2_str)
105
+ assert_equal expected, (v1 <=> v2), "Expected #{v1} <=> #{v2} to be #{expected}"
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+ require 'webrick'
5
+ require 'ostruct'
6
+ require 'json'
7
+
8
+ class TestSSEConfigClient < Minitest::Test
9
+ def test_connect_url_is_api_v2_sse_config
10
+ prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'test')
11
+ config_loader = OpenStruct.new(highwater_mark: 0)
12
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
13
+
14
+ captured_url = nil
15
+ fake = OpenStruct.new(closed?: false)
16
+ fake.define_singleton_method(:on_event) { |&_b| }
17
+ fake.define_singleton_method(:on_error) { |&_b| }
18
+ fake.define_singleton_method(:close) { }
19
+
20
+ SSE::Client.stub :new, ->(url, *_args, **_kwargs, &block) {
21
+ captured_url = url
22
+ block.call(fake) if block
23
+ fake
24
+ } do
25
+ client.connect { |_e, _ev, _s| }
26
+ end
27
+
28
+ assert_equal 'https://stream.example.com/api/v2/sse/config', captured_url
29
+ end
30
+
31
+ def test_on_event_parses_json_into_config_envelope
32
+ prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'test')
33
+ config_loader = OpenStruct.new(highwater_mark: 0)
34
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
35
+
36
+ captured = {}
37
+ event_handler = nil
38
+ fake = Object.new
39
+ fake.define_singleton_method(:on_event) { |&block| event_handler = block }
40
+ fake.define_singleton_method(:on_error) { |&_b| }
41
+ fake.define_singleton_method(:close) { }
42
+ fake.define_singleton_method(:closed?) { false }
43
+
44
+ SSE::Client.stub :new, ->(*_args, **_kwargs, &block) {
45
+ block.call(fake) if block
46
+ fake
47
+ } do
48
+ client.connect do |envelope, event, source|
49
+ captured[:envelope] = envelope
50
+ captured[:event] = event
51
+ captured[:source] = source
52
+ end
53
+ end
54
+
55
+ json_data = JSON.generate({
56
+ configs: [{ key: 'my.key', valueType: 'string', default: { rules: [] } }],
57
+ meta: { version: 'abc123', environment: 'prod' }
58
+ })
59
+
60
+ event_handler.call(OpenStruct.new(data: json_data))
61
+
62
+ assert_instance_of Quonfig::ConfigEnvelope, captured[:envelope]
63
+ assert_equal 1, captured[:envelope].configs.length
64
+ assert_equal 'my.key', captured[:envelope].configs[0]['key']
65
+ assert_equal 'abc123', captured[:envelope].meta['version']
66
+ assert_equal :sse, captured[:source]
67
+ end
68
+
69
+ def test_headers_basic_auth_uses_1_prefix
70
+ prefab_options = OpenStruct.new(sse_api_urls: ['https://stream.example.com'], sdk_key: 'mykey')
71
+ config_loader = OpenStruct.new(highwater_mark: 0)
72
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
73
+
74
+ h = client.headers
75
+
76
+ assert_equal "Basic #{Base64.strict_encode64('1:mykey')}", h['Authorization']
77
+ assert_match(/\Asdk-ruby-/, h['X-Quonfig-SDK-Version'])
78
+ refute h.key?('X-Reforge-SDK-Version')
79
+ end
80
+
81
+ def test_recovering_from_disconnection
82
+ server, = start_webrick_server(4567, DisconnectingEndpoint)
83
+
84
+ config_loader = OpenStruct.new(highwater_mark: 4)
85
+
86
+ prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4567'], sdk_key: 'test')
87
+ last_event_id = nil
88
+ client = nil
89
+
90
+ begin
91
+ Thread.new do
92
+ server.start
93
+ end
94
+
95
+ sse_options = Quonfig::SSEConfigClient::Options.new(
96
+ sse_default_reconnect_time: 0.1
97
+ )
98
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader, sse_options)
99
+
100
+ client.start do |_configs, event, _source|
101
+ last_event_id = event.id.to_i
102
+ end
103
+
104
+ wait_for -> { last_event_id && last_event_id > 1 }
105
+ ensure
106
+ client.close
107
+ server.stop
108
+
109
+ refute_nil last_event_id, 'Expected to have received an event'
110
+ assert last_event_id > 1, 'Expected to have received multiple events (indicating a retry)'
111
+ end
112
+ end
113
+
114
+ def test_recovering_from_an_error
115
+ log_output = StringIO.new
116
+ logger = Logger.new(log_output)
117
+
118
+ server, = start_webrick_server(4568, ErroringEndpoint)
119
+
120
+ config_loader = OpenStruct.new(highwater_mark: 4)
121
+
122
+ prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4568'], sdk_key: 'test')
123
+ last_event_id = nil
124
+ client = nil
125
+
126
+ begin
127
+ Thread.new do
128
+ server.start
129
+ end
130
+
131
+ sse_options = Quonfig::SSEConfigClient::Options.new(
132
+ sse_default_reconnect_time: 0.1,
133
+ seconds_between_new_connection: 0.1,
134
+ sleep_delay_for_new_connection_check: 0.1,
135
+ errors_to_close_connection: [SSE::Errors::HTTPStatusError]
136
+ )
137
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader, sse_options, logger)
138
+
139
+ client.start do |_configs, event, _source|
140
+ last_event_id = event.id.to_i
141
+ end
142
+
143
+ wait_for -> { last_event_id && last_event_id > 2 }
144
+ ensure
145
+ server.stop
146
+ client.close
147
+
148
+ refute_nil last_event_id, 'Expected to have received an event'
149
+ assert last_event_id > 2, 'Expected to have received multiple events (indicating a reconnect)'
150
+ end
151
+
152
+ log_lines = log_output.string.split("\n")
153
+
154
+ assert_match(/SSE Streaming Connect/, log_lines[0])
155
+ assert_match(/SSE Streaming Error/, log_lines[1], 'Expected to have logged an error. If this starts failing after an ld-eventsource upgrade, you might need to tweak NUMBER_OF_FAILURES below')
156
+ assert_match(/Closing SSE connection/, log_lines[2])
157
+ assert_match(/Reconnecting SSE client/, log_lines[3])
158
+ assert_match(/SSE Streaming Connect/, log_lines[4])
159
+ end
160
+
161
+ def start_webrick_server(port, endpoint_class)
162
+ log_string = StringIO.new
163
+ logger = WEBrick::Log.new(log_string)
164
+ server = WEBrick::HTTPServer.new(Port: port, Logger: logger, AccessLog: [])
165
+ server.mount '/api/v2/sse', endpoint_class
166
+
167
+ [server, log_string]
168
+ end
169
+
170
+ module SharedEndpointLogic
171
+ def event_id
172
+ @@event_id ||= 0
173
+ @@event_id += 1
174
+ end
175
+
176
+ def setup_response(response)
177
+ response.status = 200
178
+ response['Content-Type'] = 'text/event-stream'
179
+ response['Cache-Control'] = 'no-cache'
180
+ response['Connection'] = 'keep-alive'
181
+
182
+ response.chunked = false
183
+ end
184
+ end
185
+
186
+ SAMPLE_JSON_PAYLOAD = '{"configs":[],"meta":{"version":"1","environment":"test"}}'
187
+
188
+ class DisconnectingEndpoint < WEBrick::HTTPServlet::AbstractServlet
189
+ include SharedEndpointLogic
190
+
191
+ def do_GET(_request, response)
192
+ setup_response(response)
193
+
194
+ output = response.body
195
+
196
+ output << "id: #{event_id}\n"
197
+ output << "event: message\n"
198
+ output << "data: #{SAMPLE_JSON_PAYLOAD}\n\n"
199
+ end
200
+ end
201
+
202
+ class ErroringEndpoint < WEBrick::HTTPServlet::AbstractServlet
203
+ include SharedEndpointLogic
204
+ NUMBER_OF_FAILURES = 5
205
+
206
+ def do_GET(_request, response)
207
+ setup_response(response)
208
+
209
+ output = response.body
210
+
211
+ output << "id: #{event_id}\n"
212
+
213
+ if event_id < NUMBER_OF_FAILURES
214
+ raise 'ErroringEndpoint' # This manifests as an SSE::Errors::HTTPStatusError
215
+ end
216
+
217
+ output << "event: message\n"
218
+ output << "data: #{SAMPLE_JSON_PAYLOAD}\n\n"
219
+ end
220
+ end
221
+
222
+ def test_empty_data_validation
223
+ # Unit test to verify that empty data is properly detected and handled
224
+ log_output = StringIO.new
225
+ logger = Logger.new(log_output)
226
+
227
+ # Test that empty event.data is detected
228
+ mock_event = OpenStruct.new(data: '')
229
+ mock_client = Minitest::Mock.new
230
+ mock_client.expect(:close, nil)
231
+
232
+ # Simulate the on_event handler logic
233
+ if mock_event.data.nil? || mock_event.data.empty?
234
+ logger.error "SSE Streaming Error: Received empty data for url http://test"
235
+ mock_client.close
236
+ end
237
+
238
+ log_lines = log_output.string.split("\n")
239
+ assert log_lines.any? { |line| line.include?('SSE Streaming Error') && line.include?('empty data') },
240
+ 'Expected to have logged an error about empty data'
241
+ mock_client.verify
242
+
243
+ # Test that nil event.data is detected
244
+ log_output = StringIO.new
245
+ logger = Logger.new(log_output)
246
+ mock_event = OpenStruct.new(data: nil)
247
+ mock_client = Minitest::Mock.new
248
+ mock_client.expect(:close, nil)
249
+
250
+ if mock_event.data.nil? || mock_event.data.empty?
251
+ logger.error "SSE Streaming Error: Received empty data for url http://test"
252
+ mock_client.close
253
+ end
254
+
255
+ log_lines = log_output.string.split("\n")
256
+ assert log_lines.any? { |line| line.include?('SSE Streaming Error') && line.include?('empty data') },
257
+ 'Expected to have logged an error about empty data for nil'
258
+ mock_client.verify
259
+ end
260
+
261
+ def test_last_event_id_initialization
262
+ # Test with positive highwater_mark
263
+ config_loader = OpenStruct.new(highwater_mark: 42)
264
+ prefab_options = OpenStruct.new(sse_api_urls: ['http://localhost:4567'], sdk_key: 'test')
265
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
266
+
267
+ # Mock SSE::Client.new to capture the last_event_id argument
268
+ SSE::Client.stub :new, ->(*args, **kwargs, &block) {
269
+ assert_equal '42', kwargs[:last_event_id], 'Expected last_event_id to be "42"'
270
+ OpenStruct.new(closed?: false, close: nil)
271
+ } do
272
+ client.connect { |_configs, _event, _source| }
273
+ end
274
+
275
+ # Test with nil highwater_mark
276
+ config_loader = OpenStruct.new(highwater_mark: nil)
277
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
278
+
279
+ SSE::Client.stub :new, ->(*args, **kwargs, &block) {
280
+ assert_nil kwargs[:last_event_id], 'Expected last_event_id to be nil when highwater_mark is nil'
281
+ OpenStruct.new(closed?: false, close: nil)
282
+ } do
283
+ client.connect { |_configs, _event, _source| }
284
+ end
285
+
286
+ # Test with zero highwater_mark
287
+ config_loader = OpenStruct.new(highwater_mark: 0)
288
+ client = Quonfig::SSEConfigClient.new(prefab_options, config_loader)
289
+
290
+ SSE::Client.stub :new, ->(*args, **kwargs, &block) {
291
+ assert_nil kwargs[:last_event_id], 'Expected last_event_id to be nil when highwater_mark is 0'
292
+ OpenStruct.new(closed?: false, close: nil)
293
+ } do
294
+ client.connect { |_configs, _event, _source| }
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ # Typed getters (get_string / get_int / get_bool / get_string_list /
6
+ # get_duration / get_json) on Quonfig::Client. Each verifies both the happy
7
+ # path against an injected ConfigStore and the type-mismatch error path.
8
+ class TestTypedGetters < Minitest::Test
9
+ KEY = 'my.key'
10
+
11
+ def make_config(value:, type:)
12
+ {
13
+ 'id' => '1',
14
+ 'key' => KEY,
15
+ 'type' => 'config',
16
+ 'valueType' => type,
17
+ 'sendToClientSdk' => false,
18
+ 'default' => {
19
+ 'rules' => [
20
+ {
21
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
22
+ 'value' => { 'type' => type, 'value' => value }
23
+ }
24
+ ]
25
+ },
26
+ 'environment' => nil
27
+ }
28
+ end
29
+
30
+ def client_with_value(value:, type:)
31
+ store = Quonfig::ConfigStore.new
32
+ store.set(KEY, make_config(value: value, type: type))
33
+ Quonfig::Client.new(Quonfig::Options.new, store: store)
34
+ end
35
+
36
+ # ---- get_string -------------------------------------------------------
37
+
38
+ def test_get_string_returns_string
39
+ assert_equal 'hello', client_with_value(value: 'hello', type: 'string').get_string(KEY)
40
+ end
41
+
42
+ def test_get_string_raises_on_non_string
43
+ assert_raises(Quonfig::Errors::TypeMismatchError) do
44
+ client_with_value(value: 42, type: 'int').get_string(KEY)
45
+ end
46
+ end
47
+
48
+ def test_get_string_default_when_missing
49
+ client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
50
+ assert_equal 'fallback', client.get_string('nope', default: 'fallback')
51
+ end
52
+
53
+ # ---- get_int ----------------------------------------------------------
54
+
55
+ def test_get_int_returns_integer
56
+ assert_equal 42, client_with_value(value: 42, type: 'int').get_int(KEY)
57
+ end
58
+
59
+ def test_get_int_raises_on_string
60
+ assert_raises(Quonfig::Errors::TypeMismatchError) do
61
+ client_with_value(value: 'oops', type: 'string').get_int(KEY)
62
+ end
63
+ end
64
+
65
+ # ---- get_float --------------------------------------------------------
66
+
67
+ def test_get_float_returns_float
68
+ assert_in_delta 3.14, client_with_value(value: 3.14, type: 'double').get_float(KEY), 0.0001
69
+ end
70
+
71
+ def test_get_float_raises_on_non_float
72
+ assert_raises(Quonfig::Errors::TypeMismatchError) do
73
+ client_with_value(value: 'oops', type: 'string').get_float(KEY)
74
+ end
75
+ end
76
+
77
+ # ---- get_bool ---------------------------------------------------------
78
+
79
+ def test_get_bool_returns_true
80
+ assert_equal true, client_with_value(value: true, type: 'bool').get_bool(KEY)
81
+ end
82
+
83
+ def test_get_bool_returns_false
84
+ assert_equal false, client_with_value(value: false, type: 'bool').get_bool(KEY)
85
+ end
86
+
87
+ def test_get_bool_raises_on_string
88
+ assert_raises(Quonfig::Errors::TypeMismatchError) do
89
+ client_with_value(value: 'true', type: 'string').get_bool(KEY)
90
+ end
91
+ end
92
+
93
+ # ---- get_string_list --------------------------------------------------
94
+
95
+ def test_get_string_list_returns_array_of_strings
96
+ assert_equal %w[a b c], client_with_value(value: %w[a b c], type: 'string_list').get_string_list(KEY)
97
+ end
98
+
99
+ def test_get_string_list_raises_on_non_array
100
+ assert_raises(Quonfig::Errors::TypeMismatchError) do
101
+ client_with_value(value: 'a,b,c', type: 'string').get_string_list(KEY)
102
+ end
103
+ end
104
+
105
+ # ---- get_duration -----------------------------------------------------
106
+
107
+ def test_get_duration_returns_milliseconds_for_iso_string
108
+ # ISO-8601 PT1S -> 1 second -> 1000 ms.
109
+ client = client_with_value(value: 'PT1S', type: 'duration')
110
+ assert_equal 1000, client.get_duration(KEY)
111
+ end
112
+
113
+ def test_get_duration_passes_through_numeric
114
+ client = client_with_value(value: 5000, type: 'int')
115
+ assert_equal 5000, client.get_duration(KEY)
116
+ end
117
+
118
+ # ---- get_json ---------------------------------------------------------
119
+
120
+ def test_get_json_returns_hash_unchanged
121
+ payload = { 'a' => 1, 'b' => [1, 2, 3] }
122
+ client = client_with_value(value: payload, type: 'json')
123
+ assert_equal payload, client.get_json(KEY)
124
+ end
125
+
126
+ def test_get_json_returns_array_unchanged
127
+ payload = [1, 2, 3]
128
+ client = client_with_value(value: payload, type: 'json')
129
+ assert_equal payload, client.get_json(KEY)
130
+ end
131
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+ require 'json'
5
+ require 'quonfig/types'
6
+
7
+ class TestTypes < Minitest::Test
8
+ def test_value_holds_type_and_value
9
+ v = Quonfig::Value.new(type: 'double', value: '9.95')
10
+ assert_equal 'double', v.type
11
+ assert_equal '9.95', v.value
12
+ assert_nil v.confidential
13
+ assert_nil v.decrypt_with
14
+ end
15
+
16
+ def test_criterion_fields_accept_nil
17
+ c = Quonfig::Criterion.new(operator: 'ALWAYS_TRUE')
18
+ assert_equal 'ALWAYS_TRUE', c.operator
19
+ assert_nil c.property_name
20
+ assert_nil c.value_to_match
21
+ end
22
+
23
+ def test_rule_wires_criteria_and_value
24
+ value = Quonfig::Value.new(type: 'double', value: '9.95')
25
+ criterion = Quonfig::Criterion.new(operator: 'ALWAYS_TRUE')
26
+ rule = Quonfig::Rule.new(criteria: [criterion], value: value)
27
+ assert_equal [criterion], rule.criteria
28
+ assert_same value, rule.value
29
+ end
30
+
31
+ def test_rule_set_holds_rules_array
32
+ rs = Quonfig::RuleSet.new(rules: [])
33
+ assert_equal [], rs.rules
34
+ end
35
+
36
+ def test_environment_holds_id_and_rules
37
+ env = Quonfig::Environment.new(id: 'env-1', rules: [])
38
+ assert_equal 'env-1', env.id
39
+ assert_equal [], env.rules
40
+ end
41
+
42
+ def test_meta_optional_workspace_id
43
+ meta = Quonfig::Meta.new(version: '1', environment: 'production')
44
+ assert_nil meta.workspace_id
45
+ end
46
+
47
+ def test_weighted_value_and_weighted_values_data
48
+ wv = Quonfig::WeightedValue.new(weight: 100, value: Quonfig::Value.new(type: 'bool', value: true))
49
+ assert_equal 100, wv.weight
50
+ wvd = Quonfig::WeightedValuesData.new(weighted_values: [wv])
51
+ assert_equal [wv], wvd.weighted_values
52
+ assert_nil wvd.hash_by_property_name
53
+ end
54
+
55
+ def test_schema_data
56
+ sd = Quonfig::SchemaData.new(schema_type: 'zod', schema: 'z.string()')
57
+ assert_equal 'zod', sd.schema_type
58
+ assert_equal 'z.string()', sd.schema
59
+ end
60
+
61
+ def test_provided_data
62
+ pd = Quonfig::ProvidedData.new(source: 'ENV_VAR', lookup: 'DATABASE_URL')
63
+ assert_equal 'ENV_VAR', pd.source
64
+ assert_equal 'DATABASE_URL', pd.lookup
65
+ end
66
+
67
+ def test_config_response_from_integration_fixture
68
+ path = File.expand_path(
69
+ '../../integration-test-data/data/integration-tests/configs/my-double-key.json',
70
+ __dir__
71
+ )
72
+ skip "integration-test-data not present at #{path}" unless File.exist?(path)
73
+
74
+ raw = JSON.parse(File.read(path))
75
+ default_rules = raw.fetch('default').fetch('rules').map do |rule|
76
+ criteria = rule.fetch('criteria').map do |c|
77
+ Quonfig::Criterion.new(
78
+ property_name: c['propertyName'],
79
+ operator: c.fetch('operator'),
80
+ value_to_match: c['valueToMatch']
81
+ )
82
+ end
83
+ value = rule.fetch('value')
84
+ Quonfig::Rule.new(
85
+ criteria: criteria,
86
+ value: Quonfig::Value.new(type: value['type'], value: value['value'])
87
+ )
88
+ end
89
+
90
+ response = Quonfig::ConfigResponse.new(
91
+ id: raw.fetch('id'),
92
+ key: raw.fetch('key'),
93
+ type: raw.fetch('type'),
94
+ value_type: raw.fetch('valueType'),
95
+ send_to_client_sdk: raw.fetch('sendToClientSdk'),
96
+ default: Quonfig::RuleSet.new(rules: default_rules)
97
+ )
98
+
99
+ assert_equal 'my-double-key', response.key
100
+ assert_equal 'config', response.type
101
+ assert_equal 'double', response.value_type
102
+ refute response.send_to_client_sdk
103
+ assert_nil response.environment
104
+ assert_equal 1, response.default.rules.length
105
+
106
+ rule = response.default.rules.first
107
+ assert_equal 'ALWAYS_TRUE', rule.criteria.first.operator
108
+ assert_equal 'double', rule.value.type
109
+ assert_equal '9.95', rule.value.value
110
+ end
111
+
112
+ def test_config_envelope_carries_configs_and_meta
113
+ meta = Quonfig::Meta.new(version: '1', environment: 'production')
114
+ envelope = Quonfig::ConfigEnvelope.new(configs: [], meta: meta)
115
+ assert_equal [], envelope.configs
116
+ assert_same meta, envelope.meta
117
+ end
118
+
119
+ def test_workspace_config_document_fields
120
+ doc = Quonfig::WorkspaceConfigDocument.new(
121
+ id: 'id-1',
122
+ key: 'feature.x',
123
+ type: 'feature_flag',
124
+ value_type: 'bool',
125
+ send_to_client_sdk: true,
126
+ default: Quonfig::RuleSet.new(rules: []),
127
+ environments: []
128
+ )
129
+ assert_equal 'feature.x', doc.key
130
+ assert_equal 'feature_flag', doc.type
131
+ assert_equal 'bool', doc.value_type
132
+ assert doc.send_to_client_sdk
133
+ assert_equal [], doc.environments
134
+ end
135
+
136
+ def test_workspace_environment_fields
137
+ we = Quonfig::WorkspaceEnvironment.new(id: 'env-1', rules: [])
138
+ assert_equal 'env-1', we.id
139
+ assert_equal [], we.rules
140
+ end
141
+ end