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_semver.rb
ADDED
|
@@ -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
|
data/test/test_types.rb
ADDED
|
@@ -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
|