quonfig 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- metadata +311 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestOptions < Minitest::Test
|
|
6
|
+
API_KEY = 'abcdefg'
|
|
7
|
+
|
|
8
|
+
def test_api_urls_override_env_var
|
|
9
|
+
assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
|
|
10
|
+
|
|
11
|
+
# blank doesn't take effect
|
|
12
|
+
with_env('QUONFIG_API_URLS', '') do
|
|
13
|
+
assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# non-blank does take effect
|
|
17
|
+
with_env('QUONFIG_API_URLS', 'https://override.example.com') do
|
|
18
|
+
assert_equal ["https://override.example.com"], Quonfig::Options.new.api_urls
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def test_default_api_urls_point_to_quonfig
|
|
23
|
+
assert_equal [
|
|
24
|
+
'https://primary.quonfig.com',
|
|
25
|
+
], Quonfig::Options::DEFAULT_API_URLS
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_overriding_api_urls
|
|
29
|
+
assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
|
|
30
|
+
|
|
31
|
+
# a plain string ends up wrapped in an array
|
|
32
|
+
api_url = 'https://example.com'
|
|
33
|
+
assert_equal [api_url], Quonfig::Options.new(api_urls: api_url).api_urls
|
|
34
|
+
|
|
35
|
+
api_urls = ['https://example.com', 'https://example2.com']
|
|
36
|
+
assert_equal api_urls, Quonfig::Options.new(api_urls: api_urls).api_urls
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_derive_stream_url_prepends_stream_to_hostname
|
|
40
|
+
assert_equal 'https://stream.primary.quonfig.com',
|
|
41
|
+
Quonfig::Options.derive_stream_url('https://primary.quonfig.com')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_derive_stream_url_preserves_port
|
|
45
|
+
assert_equal 'http://stream.localhost:6550',
|
|
46
|
+
Quonfig::Options.derive_stream_url('http://localhost:6550')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_derive_stream_url_preserves_scheme_and_path
|
|
50
|
+
assert_equal 'http://stream.api.example.com/base',
|
|
51
|
+
Quonfig::Options.derive_stream_url('http://api.example.com/base')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_derive_stream_url_with_eu_subdomain
|
|
55
|
+
assert_equal 'https://stream.primary.eu.quonfig.com',
|
|
56
|
+
Quonfig::Options.derive_stream_url('https://primary.eu.quonfig.com')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_works_with_named_arguments
|
|
60
|
+
assert_equal API_KEY, Quonfig::Options.new(sdk_key: API_KEY).sdk_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def test_works_with_hash
|
|
64
|
+
assert_equal API_KEY, Quonfig::Options.new({ sdk_key: API_KEY }).sdk_key
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_sdk_key_reads_from_quonfig_backend_sdk_key
|
|
68
|
+
with_env('QUONFIG_BACKEND_SDK_KEY', 'env-key') do
|
|
69
|
+
assert_equal 'env-key', Quonfig::Options.new.sdk_key
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_environment_reads_from_quonfig_environment
|
|
74
|
+
with_env('QUONFIG_ENVIRONMENT', 'staging') do
|
|
75
|
+
assert_equal 'staging', Quonfig::Options.new.environment
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_environment_explicit_overrides_env_var
|
|
80
|
+
with_env('QUONFIG_ENVIRONMENT', 'staging') do
|
|
81
|
+
assert_equal 'production', Quonfig::Options.new(environment: 'production').environment
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_enable_sse_defaults_true
|
|
86
|
+
assert_equal true, Quonfig::Options.new.enable_sse
|
|
87
|
+
assert_equal false, Quonfig::Options.new(enable_sse: false).enable_sse
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_enable_polling_defaults_true
|
|
91
|
+
assert_equal true, Quonfig::Options.new.enable_polling
|
|
92
|
+
assert_equal false, Quonfig::Options.new(enable_polling: false).enable_polling
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_datadir_reads_from_quonfig_dir_env
|
|
96
|
+
with_env('QUONFIG_DIR', '/tmp/some/workspace') do
|
|
97
|
+
assert_equal '/tmp/some/workspace', Quonfig::Options.new.datadir
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_datadir_explicit_overrides_env_var
|
|
102
|
+
with_env('QUONFIG_DIR', '/tmp/env/workspace') do
|
|
103
|
+
assert_equal '/tmp/explicit', Quonfig::Options.new(datadir: '/tmp/explicit').datadir
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_datadir_predicate
|
|
108
|
+
assert_equal false, Quonfig::Options.new.datadir?
|
|
109
|
+
assert_equal true, Quonfig::Options.new(datadir: '/tmp/ws').datadir?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_local_only_uses_datadir_presence
|
|
113
|
+
refute Quonfig::Options.new.local_only?
|
|
114
|
+
assert Quonfig::Options.new(datadir: '/tmp/ws').local_only?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def test_collect_max_paths
|
|
118
|
+
assert_equal 1000, Quonfig::Options.new.collect_max_paths
|
|
119
|
+
assert_equal 100, Quonfig::Options.new(collect_max_paths: 100).collect_max_paths
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def test_collect_max_evaluation_summaries
|
|
123
|
+
assert_equal 100_000, Quonfig::Options.new.collect_max_evaluation_summaries
|
|
124
|
+
assert_equal 0, Quonfig::Options.new(collect_evaluation_summaries: false).collect_max_evaluation_summaries
|
|
125
|
+
assert_equal 3,
|
|
126
|
+
Quonfig::Options.new(collect_max_evaluation_summaries: 3).collect_max_evaluation_summaries
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def test_context_upload_mode_periodic
|
|
130
|
+
options = Quonfig::Options.new(context_upload_mode: :periodic_example, context_max_size: 100)
|
|
131
|
+
assert_equal 100, options.collect_max_example_contexts
|
|
132
|
+
|
|
133
|
+
options = Quonfig::Options.new(context_upload_mode: :none)
|
|
134
|
+
assert_equal 0, options.collect_max_example_contexts
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_context_upload_mode_shapes_only
|
|
138
|
+
options = Quonfig::Options.new(context_upload_mode: :shapes_only, context_max_size: 100)
|
|
139
|
+
assert_equal 100, options.collect_max_shapes
|
|
140
|
+
|
|
141
|
+
options = Quonfig::Options.new(context_upload_mode: :none)
|
|
142
|
+
assert_equal 0, options.collect_max_shapes
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def test_context_upload_mode_none
|
|
146
|
+
options = Quonfig::Options.new(context_upload_mode: :none)
|
|
147
|
+
assert_equal 0, options.collect_max_example_contexts
|
|
148
|
+
|
|
149
|
+
options = Quonfig::Options.new(context_upload_mode: :none)
|
|
150
|
+
assert_equal 0, options.collect_max_shapes
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_telemetry_destination_reads_env_first
|
|
154
|
+
with_env('QUONFIG_TELEMETRY_URL', 'https://custom-telemetry.example.com') do
|
|
155
|
+
assert_equal 'https://custom-telemetry.example.com', Quonfig::Options.new.telemetry_destination
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_telemetry_destination_derives_from_default_sources
|
|
160
|
+
assert_equal 'https://telemetry.quonfig.com', Quonfig::Options.new.telemetry_destination
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def test_telemetry_destination_derives_from_custom_quonfig_api_urls
|
|
164
|
+
options = Quonfig::Options.new(api_urls: ['https://primary.eu.quonfig.com'])
|
|
165
|
+
assert_equal 'https://telemetry.eu.quonfig.com', options.telemetry_destination
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'timecop'
|
|
5
|
+
|
|
6
|
+
class RateLimitCacheTest < Minitest::Test
|
|
7
|
+
def test_set_and_fresh
|
|
8
|
+
cache = Quonfig::RateLimitCache.new(5)
|
|
9
|
+
cache.set('key')
|
|
10
|
+
assert cache.fresh?('key')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_fresh_with_no_set
|
|
14
|
+
cache = Quonfig::RateLimitCache.new(5)
|
|
15
|
+
refute cache.fresh?('key')
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_get_after_expiration
|
|
19
|
+
cache = Quonfig::RateLimitCache.new(5)
|
|
20
|
+
|
|
21
|
+
Timecop.freeze(Time.now - 6) do
|
|
22
|
+
cache.set('key')
|
|
23
|
+
assert cache.fresh?('key')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
refute cache.fresh?('key')
|
|
27
|
+
|
|
28
|
+
# but the data is still there
|
|
29
|
+
assert cache.data.get('key')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_prune
|
|
33
|
+
cache = Quonfig::RateLimitCache.new(5)
|
|
34
|
+
|
|
35
|
+
Timecop.freeze(Time.now - 6) do
|
|
36
|
+
cache.set('key')
|
|
37
|
+
assert cache.fresh?('key')
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
cache.prune
|
|
41
|
+
|
|
42
|
+
refute cache.fresh?('key')
|
|
43
|
+
end
|
|
44
|
+
end
|
data/test/test_reason.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'quonfig/reason'
|
|
5
|
+
|
|
6
|
+
# Tests Quonfig::Reason in isolation — uses plain Structs so the test does not
|
|
7
|
+
# depend on PrefabProto (which is not loaded by the current bootstrap).
|
|
8
|
+
class TestReason < Minitest::Test
|
|
9
|
+
FakeCriterion = Struct.new(:operator)
|
|
10
|
+
FakeConditionalValue = Struct.new(:criteria)
|
|
11
|
+
FakeRow = Struct.new(:values, :project_env_id)
|
|
12
|
+
FakeConfig = Struct.new(:rows)
|
|
13
|
+
|
|
14
|
+
ALWAYS_TRUE = FakeCriterion.new(:ALWAYS_TRUE)
|
|
15
|
+
PROP_MATCH = FakeCriterion.new(:PROP_IS_ONE_OF)
|
|
16
|
+
|
|
17
|
+
DEFAULT_CV = FakeConditionalValue.new([])
|
|
18
|
+
ALWAYS_TRUE_CV = FakeConditionalValue.new([ALWAYS_TRUE])
|
|
19
|
+
RULE_CV = FakeConditionalValue.new([PROP_MATCH])
|
|
20
|
+
|
|
21
|
+
def default_only_config
|
|
22
|
+
FakeConfig.new([FakeRow.new([DEFAULT_CV], 0)])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def targeted_config
|
|
26
|
+
FakeConfig.new([
|
|
27
|
+
FakeRow.new([DEFAULT_CV], 0),
|
|
28
|
+
FakeRow.new([RULE_CV, ALWAYS_TRUE_CV], 1)
|
|
29
|
+
])
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_default_for_default_only_config
|
|
33
|
+
reason = Quonfig::Reason.compute(
|
|
34
|
+
config: default_only_config,
|
|
35
|
+
conditional_value: DEFAULT_CV
|
|
36
|
+
)
|
|
37
|
+
assert_equal :DEFAULT, reason
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_rule_match_when_targeting_rule_matched
|
|
41
|
+
reason = Quonfig::Reason.compute(
|
|
42
|
+
config: targeted_config,
|
|
43
|
+
conditional_value: RULE_CV
|
|
44
|
+
)
|
|
45
|
+
assert_equal :RULE_MATCH, reason
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_rule_match_when_falling_back_to_always_true_in_targeted_config
|
|
49
|
+
reason = Quonfig::Reason.compute(
|
|
50
|
+
config: targeted_config,
|
|
51
|
+
conditional_value: ALWAYS_TRUE_CV
|
|
52
|
+
)
|
|
53
|
+
assert_equal :RULE_MATCH, reason
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_split_when_weighted_value_index_positive
|
|
57
|
+
reason = Quonfig::Reason.compute(
|
|
58
|
+
config: default_only_config,
|
|
59
|
+
conditional_value: DEFAULT_CV,
|
|
60
|
+
weighted_value_index: 2
|
|
61
|
+
)
|
|
62
|
+
assert_equal :SPLIT, reason
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_weighted_value_index_zero_is_not_split
|
|
66
|
+
reason = Quonfig::Reason.compute(
|
|
67
|
+
config: default_only_config,
|
|
68
|
+
conditional_value: DEFAULT_CV,
|
|
69
|
+
weighted_value_index: 0
|
|
70
|
+
)
|
|
71
|
+
assert_equal :DEFAULT, reason
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_default_when_only_always_true_criteria_and_no_targeting_rules
|
|
75
|
+
config = FakeConfig.new([FakeRow.new([ALWAYS_TRUE_CV], 0)])
|
|
76
|
+
reason = Quonfig::Reason.compute(config: config, conditional_value: ALWAYS_TRUE_CV)
|
|
77
|
+
assert_equal :DEFAULT, reason
|
|
78
|
+
end
|
|
79
|
+
end
|
data/test/test_rename.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestRename < Minitest::Test
|
|
6
|
+
def test_quonfig_module_is_defined
|
|
7
|
+
assert defined?(Quonfig), 'Quonfig module must be defined after rename'
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def test_reforge_module_is_gone
|
|
11
|
+
# NOTE: 'Reforge' intentionally not renamed — this test guards against the old constant leaking back in.
|
|
12
|
+
refute Object.const_defined?(:Reforge), 'Reforge constant must be removed after rename'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_quonfig_options_exists
|
|
16
|
+
assert defined?(Quonfig::Options), 'Quonfig::Options must be defined'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_quonfig_client_exists
|
|
20
|
+
assert defined?(Quonfig::Client), 'Quonfig::Client must be defined'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_quonfig_sdk_key_env_var
|
|
24
|
+
# NOTE: the REFORGE_/PREFAB_ keys below are intentionally spelled out in string literals
|
|
25
|
+
# so the bulk rename tool does not touch them — we are asserting that the NEW env var name
|
|
26
|
+
# is the one the SDK reads.
|
|
27
|
+
old_key_a = 'REFORGE_' + 'BACKEND_SDK_KEY'
|
|
28
|
+
old_key_b = 'PREFAB_' + 'API_KEY'
|
|
29
|
+
original = ENV.to_h.slice('QUONFIG_BACKEND_SDK_KEY', old_key_a, old_key_b)
|
|
30
|
+
ENV.delete(old_key_a)
|
|
31
|
+
ENV.delete(old_key_b)
|
|
32
|
+
ENV['QUONFIG_BACKEND_SDK_KEY'] = 'quonfig-test-key-123'
|
|
33
|
+
options = Quonfig::Options.new
|
|
34
|
+
assert_equal 'quonfig-test-key-123', options.sdk_key
|
|
35
|
+
ensure
|
|
36
|
+
ENV.delete('QUONFIG_BACKEND_SDK_KEY')
|
|
37
|
+
original&.each { |k, v| ENV[k] = v }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_gemspec_file_is_quonfig
|
|
41
|
+
root = File.expand_path('..', __dir__)
|
|
42
|
+
assert File.exist?(File.join(root, 'quonfig.gemspec')),
|
|
43
|
+
'quonfig.gemspec must exist at repo root'
|
|
44
|
+
refute File.exist?(File.join(root, 'sdk-reforge.gemspec')),
|
|
45
|
+
'sdk-reforge.gemspec must be removed'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_gemspec_name_is_quonfig
|
|
49
|
+
root = File.expand_path('..', __dir__)
|
|
50
|
+
spec = Gem::Specification.load(File.join(root, 'quonfig.gemspec'))
|
|
51
|
+
assert_equal 'quonfig', spec.name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_lib_entrypoint_renamed
|
|
55
|
+
root = File.expand_path('..', __dir__)
|
|
56
|
+
assert File.exist?(File.join(root, 'lib', 'quonfig.rb')),
|
|
57
|
+
'lib/quonfig.rb must exist'
|
|
58
|
+
assert Dir.exist?(File.join(root, 'lib', 'quonfig')),
|
|
59
|
+
'lib/quonfig/ directory must exist'
|
|
60
|
+
refute File.exist?(File.join(root, 'lib', 'sdk-reforge.rb')),
|
|
61
|
+
'lib/sdk-reforge.rb must be removed'
|
|
62
|
+
refute Dir.exist?(File.join(root, 'lib', 'reforge')),
|
|
63
|
+
'lib/reforge/ directory must be removed'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Tests for the new public API trio introduced by qfg-dk6.9:
|
|
6
|
+
# store = Quonfig::ConfigStore.new(configs_hash)
|
|
7
|
+
# evaluator = Quonfig::Evaluator.new(store)
|
|
8
|
+
# resolver = Quonfig::Resolver.new(store, evaluator)
|
|
9
|
+
# result = resolver.get('my.flag', context)
|
|
10
|
+
#
|
|
11
|
+
# Mirrors the sdk-node pattern so the integration test suite (qfg-dk6.22-24)
|
|
12
|
+
# can construct these directly without a full Client.
|
|
13
|
+
#
|
|
14
|
+
# We deliberately do NOT use PrefabProto — the protobuf gem was dropped in
|
|
15
|
+
# qfg-dk6.4 and JSON Criterion types land in qfg-dk6.5 / operators port in
|
|
16
|
+
# qfg-dk6.10. These tests use minimal Struct doubles that satisfy the
|
|
17
|
+
# duck-typed shape the current CriteriaEvaluator reads.
|
|
18
|
+
class TestResolverTrio < Minitest::Test
|
|
19
|
+
CONFIG_KEY = 'my.flag'
|
|
20
|
+
DEFAULT_VALUE = 'default_value'
|
|
21
|
+
|
|
22
|
+
# qfg-dk6.10 — configs are now plain ConfigResponse-shaped hashes (symbol
|
|
23
|
+
# top-level keys + string keys inside rules/criteria). Matches what
|
|
24
|
+
# Quonfig::Datadir.to_config_response and
|
|
25
|
+
# IntegrationTestHelpers.to_config_response emit.
|
|
26
|
+
def make_default_config(key: CONFIG_KEY, value: DEFAULT_VALUE)
|
|
27
|
+
{
|
|
28
|
+
id: '1',
|
|
29
|
+
key: key,
|
|
30
|
+
type: 'config',
|
|
31
|
+
value_type: 'string',
|
|
32
|
+
send_to_client_sdk: false,
|
|
33
|
+
default: {
|
|
34
|
+
'rules' => [
|
|
35
|
+
{
|
|
36
|
+
'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
37
|
+
'value' => { 'type' => 'string', 'value' => value }
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
environment: nil
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def base_client
|
|
46
|
+
MockBaseClient.new(Quonfig::Options.new)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# ---- ConfigStore ------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def test_config_store_constructs_with_hash
|
|
52
|
+
cfg = make_default_config
|
|
53
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => cfg })
|
|
54
|
+
|
|
55
|
+
assert_equal cfg, store.get(CONFIG_KEY)
|
|
56
|
+
assert_equal [CONFIG_KEY], store.keys
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_config_store_constructs_empty
|
|
60
|
+
store = Quonfig::ConfigStore.new
|
|
61
|
+
assert_nil store.get('missing')
|
|
62
|
+
assert_empty store.keys
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_config_store_set_and_get
|
|
66
|
+
store = Quonfig::ConfigStore.new
|
|
67
|
+
cfg = make_default_config
|
|
68
|
+
store.set(CONFIG_KEY, cfg)
|
|
69
|
+
assert_equal cfg, store.get(CONFIG_KEY)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def test_config_store_all_configs_returns_hash
|
|
73
|
+
cfg = make_default_config
|
|
74
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => cfg })
|
|
75
|
+
|
|
76
|
+
all = store.all_configs
|
|
77
|
+
assert_kind_of Hash, all
|
|
78
|
+
assert_equal cfg, all[CONFIG_KEY]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_config_store_all_configs_is_a_copy
|
|
82
|
+
cfg = make_default_config
|
|
83
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => cfg })
|
|
84
|
+
store.all_configs['mutated'] = :nope
|
|
85
|
+
assert_nil store.get('mutated')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_config_store_clear_empties_the_store
|
|
89
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => make_default_config })
|
|
90
|
+
store.clear
|
|
91
|
+
assert_nil store.get(CONFIG_KEY)
|
|
92
|
+
assert_empty store.keys
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ---- Evaluator --------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def test_evaluator_accepts_store
|
|
98
|
+
store = Quonfig::ConfigStore.new
|
|
99
|
+
Quonfig::Evaluator.new(store, base_client: base_client)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ---- Resolver ---------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def test_resolver_raw_returns_config_from_store
|
|
105
|
+
cfg = make_default_config
|
|
106
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => cfg })
|
|
107
|
+
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
108
|
+
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
109
|
+
|
|
110
|
+
assert_equal cfg, resolver.raw(CONFIG_KEY)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_resolver_raw_returns_nil_for_missing_key
|
|
114
|
+
store = Quonfig::ConfigStore.new
|
|
115
|
+
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
116
|
+
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
117
|
+
|
|
118
|
+
assert_nil resolver.raw('nope')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def test_resolver_get_returns_evaluation_for_default_row_with_empty_criteria
|
|
122
|
+
cfg = make_default_config
|
|
123
|
+
store = Quonfig::ConfigStore.new({ CONFIG_KEY => cfg })
|
|
124
|
+
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
125
|
+
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
126
|
+
|
|
127
|
+
result = resolver.get(CONFIG_KEY, Quonfig::Context.new({}))
|
|
128
|
+
|
|
129
|
+
refute_nil result
|
|
130
|
+
assert_kind_of Quonfig::EvalResult, result
|
|
131
|
+
# The EvalResult exposes both the raw JSON value hash (#value) and the
|
|
132
|
+
# coerced Ruby value (#unwrapped_value). Prefer unwrapped_value for
|
|
133
|
+
# assertions — it mirrors what the real Client returns.
|
|
134
|
+
assert_equal DEFAULT_VALUE, result.unwrapped_value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_resolver_get_returns_nil_for_missing_key
|
|
138
|
+
store = Quonfig::ConfigStore.new
|
|
139
|
+
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
140
|
+
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
141
|
+
|
|
142
|
+
assert_nil resolver.get('nope', Quonfig::Context.new({}))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'semantic_logger'
|
|
5
|
+
|
|
6
|
+
# Verifies the corrected design: ONE Quonfig config gates many loggers.
|
|
7
|
+
# The filter passes `quonfig.logger-name` as a context property so customer
|
|
8
|
+
# rules can target `PROP_STARTS_WITH_ONE_OF my_app.db` etc.
|
|
9
|
+
class TestSemanticLoggerFilter < Minitest::Test
|
|
10
|
+
CONFIG_KEY = 'log-levels.my-app'
|
|
11
|
+
|
|
12
|
+
# FakeClient lets us assert the exact key + context the filter passes to
|
|
13
|
+
# the SDK without standing up a full datadir. The single-key contract is
|
|
14
|
+
# the *specific mechanism* this bead is verifying — if the filter ever
|
|
15
|
+
# regresses to a per-logger key, this captured request goes wrong.
|
|
16
|
+
class FakeClient
|
|
17
|
+
attr_reader :calls
|
|
18
|
+
|
|
19
|
+
def initialize(level)
|
|
20
|
+
@level = level
|
|
21
|
+
@calls = []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(key, default = nil, context = nil)
|
|
25
|
+
@calls << { key: key, default: default, context: context }
|
|
26
|
+
@level.nil? ? default : @level
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def make_log(name, level)
|
|
31
|
+
SemanticLogger::Log.new(name, level).tap { |log| log.level = level }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def filter_for(level)
|
|
35
|
+
client = FakeClient.new(level)
|
|
36
|
+
[Quonfig::SemanticLoggerFilter.new(client, config_key: CONFIG_KEY), client]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_calls_single_config_key_with_logger_name_in_context
|
|
40
|
+
filter, client = filter_for(:info)
|
|
41
|
+
filter.call(make_log('MyApp::Foo::Bar', :warn))
|
|
42
|
+
|
|
43
|
+
assert_equal 1, client.calls.size
|
|
44
|
+
assert_equal CONFIG_KEY, client.calls.first[:key]
|
|
45
|
+
ctx = client.calls.first[:context]
|
|
46
|
+
assert_equal({ 'quonfig' => { 'logger-name' => 'my_app.foo.bar' } }, ctx)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_passes_through_when_level_meets_configured_minimum
|
|
50
|
+
filter, _ = filter_for(:info)
|
|
51
|
+
|
|
52
|
+
assert_equal true, filter.call(make_log('Anything', :info))
|
|
53
|
+
assert_equal true, filter.call(make_log('Anything', :warn))
|
|
54
|
+
assert_equal true, filter.call(make_log('Anything', :error))
|
|
55
|
+
assert_equal true, filter.call(make_log('Anything', :fatal))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_suppresses_below_configured_minimum
|
|
59
|
+
filter, _ = filter_for(:warn)
|
|
60
|
+
|
|
61
|
+
assert_equal false, filter.call(make_log('Anything', :trace))
|
|
62
|
+
assert_equal false, filter.call(make_log('Anything', :debug))
|
|
63
|
+
assert_equal false, filter.call(make_log('Anything', :info))
|
|
64
|
+
assert_equal true, filter.call(make_log('Anything', :warn))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_missing_key_falls_through_to_semantic_logger_default
|
|
68
|
+
filter, _ = filter_for(nil) # FakeClient returns the default (nil) when configured level is nil
|
|
69
|
+
|
|
70
|
+
assert_equal true, filter.call(make_log('Anything', :trace))
|
|
71
|
+
assert_equal true, filter.call(make_log('Anything', :debug))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_logger_name_normalization
|
|
75
|
+
filter, client = filter_for(:debug)
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
'MyApp::Foo::Bar' => 'my_app.foo.bar',
|
|
79
|
+
'HTMLParser' => 'html_parser',
|
|
80
|
+
'foo' => 'foo',
|
|
81
|
+
'A::B::CDPath' => 'a.b.cd_path'
|
|
82
|
+
}.each do |raw, expected|
|
|
83
|
+
client.calls.clear
|
|
84
|
+
filter.call(make_log(raw, :info))
|
|
85
|
+
assert_equal expected, client.calls.first[:context]['quonfig']['logger-name'],
|
|
86
|
+
"normalize(#{raw.inspect}) should be #{expected.inspect}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_no_dotted_path_traversal_or_get_log_level
|
|
91
|
+
# Verifies the legacy hierarchical walk is gone — the filter must NOT
|
|
92
|
+
# synthesize keys like "log-levels.my_app" or call any `get_log_level`.
|
|
93
|
+
refute Quonfig::SemanticLoggerFilter.instance_methods.include?(:get_log_level)
|
|
94
|
+
|
|
95
|
+
filter, client = filter_for(:info)
|
|
96
|
+
filter.call(make_log('MyApp::Foo::Bar', :info))
|
|
97
|
+
|
|
98
|
+
keys = client.calls.map { |c| c[:key] }
|
|
99
|
+
assert_equal [CONFIG_KEY], keys.uniq,
|
|
100
|
+
'Filter should call exactly the configured key, never derived per-logger keys'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def test_all_six_levels_mapped_correctly
|
|
104
|
+
expected = { trace: 0, debug: 1, info: 2, warn: 3, error: 4, fatal: 5 }
|
|
105
|
+
assert_equal expected, Quonfig::SemanticLoggerFilter::LEVELS
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_string_level_from_config
|
|
109
|
+
filter, _ = filter_for('warn')
|
|
110
|
+
|
|
111
|
+
assert_equal false, filter.call(make_log('Anything', :info))
|
|
112
|
+
assert_equal true, filter.call(make_log('Anything', :warn))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def test_raises_loaderror_when_semantic_logger_missing
|
|
116
|
+
Quonfig::SemanticLoggerFilter.stub(:semantic_logger_loaded?, false) do
|
|
117
|
+
err = assert_raises(LoadError) do
|
|
118
|
+
Quonfig::SemanticLoggerFilter.new(FakeClient.new(:info), config_key: CONFIG_KEY)
|
|
119
|
+
end
|
|
120
|
+
assert_match(/semantic_logger/i, err.message)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|