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