quonfig 0.0.6 → 0.0.8
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 +4 -4
- data/CHANGELOG.md +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +109 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +8 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
class TestEvaluationSummariesAggregator < Minitest::Test
|
|
6
|
+
def make_aggregator(max_keys: 100)
|
|
7
|
+
Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max_keys)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def record_eval(agg, overrides = {})
|
|
11
|
+
defaults = {
|
|
12
|
+
config_id: 'c1',
|
|
13
|
+
config_key: 'my-test-key',
|
|
14
|
+
config_type: 'config',
|
|
15
|
+
conditional_value_index: 0,
|
|
16
|
+
weighted_value_index: nil,
|
|
17
|
+
selected_value: 'hello',
|
|
18
|
+
reason: 1
|
|
19
|
+
}
|
|
20
|
+
agg.record(**defaults.merge(overrides))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_record_dedupes_identical_evaluations_into_one_counter
|
|
24
|
+
agg = make_aggregator
|
|
25
|
+
|
|
26
|
+
record_eval(agg)
|
|
27
|
+
record_eval(agg)
|
|
28
|
+
record_eval(agg)
|
|
29
|
+
|
|
30
|
+
event = agg.drain_event
|
|
31
|
+
refute_nil event
|
|
32
|
+
summaries = event['summaries']['summaries']
|
|
33
|
+
assert_equal 1, summaries.size
|
|
34
|
+
|
|
35
|
+
counters = summaries[0]['counters']
|
|
36
|
+
assert_equal 1, counters.size
|
|
37
|
+
assert_equal 3, counters[0]['count']
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_record_creates_separate_counters_for_different_rule_indexes
|
|
41
|
+
agg = make_aggregator
|
|
42
|
+
|
|
43
|
+
record_eval(agg, conditional_value_index: 0)
|
|
44
|
+
record_eval(agg, conditional_value_index: 1)
|
|
45
|
+
record_eval(agg, conditional_value_index: 1)
|
|
46
|
+
|
|
47
|
+
summaries = agg.drain_event['summaries']['summaries']
|
|
48
|
+
assert_equal 1, summaries.size
|
|
49
|
+
|
|
50
|
+
counters = summaries[0]['counters']
|
|
51
|
+
assert_equal 2, counters.size
|
|
52
|
+
|
|
53
|
+
by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['count'] }
|
|
54
|
+
assert_equal 1, by_idx[0]
|
|
55
|
+
assert_equal 2, by_idx[1]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_record_groups_by_config_key_and_type
|
|
59
|
+
agg = make_aggregator
|
|
60
|
+
|
|
61
|
+
record_eval(agg, config_key: 'alpha', config_type: 'config')
|
|
62
|
+
record_eval(agg, config_key: 'alpha', config_type: 'config')
|
|
63
|
+
record_eval(agg, config_key: 'beta', config_type: 'feature_flag')
|
|
64
|
+
|
|
65
|
+
summaries = agg.drain_event['summaries']['summaries']
|
|
66
|
+
assert_equal 2, summaries.size
|
|
67
|
+
|
|
68
|
+
by_key = summaries.each_with_object({}) { |s, h| h[s['key']] = s }
|
|
69
|
+
assert_equal 'config', by_key['alpha']['type']
|
|
70
|
+
assert_equal 'feature_flag', by_key['beta']['type']
|
|
71
|
+
assert_equal 2, by_key['alpha']['counters'][0]['count']
|
|
72
|
+
assert_equal 1, by_key['beta']['counters'][0]['count']
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_drain_event_nil_when_empty
|
|
76
|
+
agg = make_aggregator
|
|
77
|
+
assert_nil agg.drain_event
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_drain_clears_state
|
|
81
|
+
agg = make_aggregator
|
|
82
|
+
record_eval(agg)
|
|
83
|
+
|
|
84
|
+
refute_nil agg.drain_event
|
|
85
|
+
assert_nil agg.drain_event, 'second drain with no new records should be nil'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_drain_event_wire_shape
|
|
89
|
+
agg = make_aggregator
|
|
90
|
+
|
|
91
|
+
record_eval(agg,
|
|
92
|
+
config_id: 'cid-42',
|
|
93
|
+
config_key: 'my-test-key',
|
|
94
|
+
config_type: 'config',
|
|
95
|
+
conditional_value_index: 1,
|
|
96
|
+
weighted_value_index: nil,
|
|
97
|
+
selected_value: 'my-test-value',
|
|
98
|
+
reason: 2)
|
|
99
|
+
|
|
100
|
+
event = agg.drain_event
|
|
101
|
+
refute_nil event
|
|
102
|
+
assert event.key?('summaries'), 'top-level event key is summaries'
|
|
103
|
+
|
|
104
|
+
inner = event['summaries']
|
|
105
|
+
assert_kind_of Integer, inner['start']
|
|
106
|
+
assert_kind_of Integer, inner['end']
|
|
107
|
+
assert inner['end'] >= inner['start']
|
|
108
|
+
|
|
109
|
+
summary = inner['summaries'][0]
|
|
110
|
+
assert_equal 'my-test-key', summary['key']
|
|
111
|
+
assert_equal 'config', summary['type']
|
|
112
|
+
|
|
113
|
+
counter = summary['counters'][0]
|
|
114
|
+
assert_equal 'cid-42', counter['configId']
|
|
115
|
+
assert_equal 1, counter['conditionalValueIndex']
|
|
116
|
+
assert_equal 0, counter['configRowIndex']
|
|
117
|
+
assert_equal 1, counter['count']
|
|
118
|
+
assert_equal 2, counter['reason']
|
|
119
|
+
assert_equal({ 'string' => 'my-test-value' }, counter['selectedValue'])
|
|
120
|
+
refute counter.key?('weightedValueIndex'),
|
|
121
|
+
'weightedValueIndex omitted when nil'
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_selected_value_wrapper_keys_match_prefab_shape
|
|
125
|
+
agg = make_aggregator
|
|
126
|
+
|
|
127
|
+
record_eval(agg, selected_value: true, conditional_value_index: 0)
|
|
128
|
+
record_eval(agg, selected_value: 3, conditional_value_index: 1)
|
|
129
|
+
record_eval(agg, selected_value: 1.5, conditional_value_index: 2)
|
|
130
|
+
record_eval(agg, selected_value: 'hi', conditional_value_index: 3)
|
|
131
|
+
record_eval(agg, selected_value: %w[a b], conditional_value_index: 4)
|
|
132
|
+
|
|
133
|
+
counters = agg.drain_event['summaries']['summaries'][0]['counters']
|
|
134
|
+
by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['selectedValue'] }
|
|
135
|
+
|
|
136
|
+
assert_equal({ 'bool' => true }, by_idx[0])
|
|
137
|
+
assert_equal({ 'int' => 3 }, by_idx[1])
|
|
138
|
+
assert_equal({ 'double' => 1.5 }, by_idx[2])
|
|
139
|
+
assert_equal({ 'string' => 'hi' }, by_idx[3])
|
|
140
|
+
assert_equal({ 'stringList' => %w[a b] }, by_idx[4])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def test_weighted_value_index_included_when_present
|
|
144
|
+
agg = make_aggregator
|
|
145
|
+
|
|
146
|
+
record_eval(agg, weighted_value_index: 2, reason: 3)
|
|
147
|
+
|
|
148
|
+
counter = agg.drain_event['summaries']['summaries'][0]['counters'][0]
|
|
149
|
+
assert_equal 2, counter['weightedValueIndex']
|
|
150
|
+
assert_equal 3, counter['reason']
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def test_log_level_evaluations_are_excluded
|
|
154
|
+
agg = make_aggregator
|
|
155
|
+
|
|
156
|
+
record_eval(agg, config_type: 'log_level')
|
|
157
|
+
|
|
158
|
+
assert_nil agg.drain_event
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_record_caps_at_max_keys
|
|
162
|
+
agg = make_aggregator(max_keys: 2)
|
|
163
|
+
|
|
164
|
+
record_eval(agg, config_key: 'a')
|
|
165
|
+
record_eval(agg, config_key: 'b')
|
|
166
|
+
record_eval(agg, config_key: 'c') # dropped — at cap
|
|
167
|
+
|
|
168
|
+
summaries = agg.drain_event['summaries']['summaries']
|
|
169
|
+
keys = summaries.map { |s| s['key'] }.sort
|
|
170
|
+
assert_equal %w[a b], keys
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_noop_when_max_keys_zero
|
|
174
|
+
agg = make_aggregator(max_keys: 0)
|
|
175
|
+
|
|
176
|
+
record_eval(agg)
|
|
177
|
+
|
|
178
|
+
assert_nil agg.drain_event
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'timecop'
|
|
5
|
+
|
|
6
|
+
class TestExampleContextsAggregator < Minitest::Test
|
|
7
|
+
def test_record_dedupes_within_window
|
|
8
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 2)
|
|
9
|
+
|
|
10
|
+
context = Quonfig::Context.new(
|
|
11
|
+
'user' => { 'key' => 'abc' },
|
|
12
|
+
'device' => { 'key' => 'def', 'mobile' => true }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
aggregator.record(context)
|
|
16
|
+
assert_equal 1, aggregator.data.size
|
|
17
|
+
|
|
18
|
+
# Same grouped key → skipped.
|
|
19
|
+
aggregator.record(context)
|
|
20
|
+
assert_equal 1, aggregator.data.size
|
|
21
|
+
|
|
22
|
+
new_context = Quonfig::Context.new(
|
|
23
|
+
'user' => { 'key' => 'ghi', 'admin' => true },
|
|
24
|
+
'team' => { 'key' => '999' }
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
aggregator.record(new_context)
|
|
28
|
+
assert_equal 2, aggregator.data.size
|
|
29
|
+
|
|
30
|
+
# At max_contexts — next record is dropped.
|
|
31
|
+
aggregator.record(Quonfig::Context.new('user' => { 'key' => 'new' }))
|
|
32
|
+
assert_equal 2, aggregator.data.size
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_record_drops_contexts_without_a_key_property
|
|
36
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
37
|
+
|
|
38
|
+
# Anonymous contexts (no `key` / `trackingId`) produce an empty
|
|
39
|
+
# grouped_key under the sdk-node-aligned shape. The aggregator drops
|
|
40
|
+
# them so we don't ship rows the backend can't dedupe.
|
|
41
|
+
aggregator.record(Quonfig::Context.new('user' => { 'name' => 'no-key' }))
|
|
42
|
+
aggregator.record(Quonfig::Context.new('user' => { 'name' => 'still-no-key' }))
|
|
43
|
+
assert_equal 0, aggregator.data.size
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_record_with_expiry_allows_re_recording_after_window
|
|
47
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
48
|
+
|
|
49
|
+
context = Quonfig::Context.new(
|
|
50
|
+
'user' => { 'key' => 'abc' },
|
|
51
|
+
'device' => { 'key' => 'def', 'mobile' => true }
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
aggregator.record(context)
|
|
55
|
+
assert_equal 1, aggregator.data.size
|
|
56
|
+
|
|
57
|
+
Timecop.travel(Time.now + (60 * 60) - 1) do
|
|
58
|
+
aggregator.record(context)
|
|
59
|
+
assert_equal 1, aggregator.data.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Timecop.travel(Time.now + (60 * 60) + 1) do
|
|
63
|
+
aggregator.record(context)
|
|
64
|
+
assert_equal 2, aggregator.data.size
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def test_drain_event_emits_api_telemetry_wire_shape
|
|
69
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
70
|
+
|
|
71
|
+
aggregator.record(
|
|
72
|
+
Quonfig::Context.new(
|
|
73
|
+
'user' => { 'key' => 'abc' },
|
|
74
|
+
'device' => { 'key' => 'def', 'mobile' => true }
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
aggregator.record(
|
|
78
|
+
Quonfig::Context.new('user' => { 'key' => 'kev', 'name' => 'kevin', 'age' => 48.5 })
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
event = aggregator.drain_event
|
|
82
|
+
|
|
83
|
+
refute_nil event
|
|
84
|
+
assert event.key?('exampleContexts')
|
|
85
|
+
examples = event['exampleContexts']['examples']
|
|
86
|
+
assert_equal 2, examples.size
|
|
87
|
+
|
|
88
|
+
first = examples[0]
|
|
89
|
+
assert_kind_of Integer, first['timestamp']
|
|
90
|
+
assert first['timestamp'] > 0
|
|
91
|
+
|
|
92
|
+
contexts_list = first['contextSet']['contexts']
|
|
93
|
+
user_ctx = contexts_list.find { |c| c['type'] == 'user' }
|
|
94
|
+
device_ctx = contexts_list.find { |c| c['type'] == 'device' }
|
|
95
|
+
|
|
96
|
+
refute_nil user_ctx
|
|
97
|
+
refute_nil device_ctx
|
|
98
|
+
assert_equal 'abc', user_ctx['values']['key']
|
|
99
|
+
assert_equal true, device_ctx['values']['mobile']
|
|
100
|
+
|
|
101
|
+
second = examples[1]
|
|
102
|
+
user_ctx = second['contextSet']['contexts'].find { |c| c['type'] == 'user' }
|
|
103
|
+
assert_equal 'kev', user_ctx['values']['key']
|
|
104
|
+
assert_equal 'kevin', user_ctx['values']['name']
|
|
105
|
+
assert_in_delta 48.5, user_ctx['values']['age']
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_drain_event_nil_when_empty
|
|
109
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
110
|
+
assert_nil aggregator.drain_event
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_drain_clears_data
|
|
114
|
+
aggregator = Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: 10)
|
|
115
|
+
aggregator.record(Quonfig::Context.new('user' => { 'key' => 'abc' }))
|
|
116
|
+
aggregator.drain_event
|
|
117
|
+
assert_equal 0, aggregator.data.size
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -23,7 +23,7 @@ module Quonfig
|
|
|
23
23
|
|
|
24
24
|
def test_x_quonfig_sdk_version_header
|
|
25
25
|
conn = HttpConnection.new(URI, SDK_KEY).connection
|
|
26
|
-
assert_equal
|
|
26
|
+
assert_equal "ruby-#{Quonfig::VERSION}", conn.headers['X-Quonfig-SDK-Version']
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def test_no_protobuf_content_type
|
data/test/test_resolver.rb
CHANGED
|
@@ -134,11 +134,158 @@ class TestResolverTrio < Minitest::Test
|
|
|
134
134
|
assert_equal DEFAULT_VALUE, result.unwrapped_value
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
def
|
|
137
|
+
def test_resolver_get_raises_missing_default_for_missing_key
|
|
138
138
|
store = Quonfig::ConfigStore.new
|
|
139
139
|
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
140
140
|
resolver = Quonfig::Resolver.new(store, evaluator)
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
# Resolver.get raises Quonfig::Errors::MissingDefaultError when no
|
|
143
|
+
# config exists for the key (qfg-9x7 alignment with the shared YAML
|
|
144
|
+
# get_or_raise.yaml suite). Client.get catches this and folds it into
|
|
145
|
+
# the on_no_default policy / caller-supplied default.
|
|
146
|
+
assert_raises(Quonfig::Errors::MissingDefaultError) do
|
|
147
|
+
resolver.get('nope', Quonfig::Context.new({}))
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ---- ENV_VAR provided value resolution (qfg-08q) ---------------------
|
|
152
|
+
|
|
153
|
+
# Build a config whose value comes from a `provided` ENV_VAR lookup.
|
|
154
|
+
# value_type drives coercion of the env var string back to the SDK type
|
|
155
|
+
# (mirrors sdk-node/sdk-go behavior).
|
|
156
|
+
def make_provided_config(key:, value_type:, lookup:)
|
|
157
|
+
{
|
|
158
|
+
id: '1',
|
|
159
|
+
key: key,
|
|
160
|
+
type: 'config',
|
|
161
|
+
value_type: value_type,
|
|
162
|
+
send_to_client_sdk: false,
|
|
163
|
+
default: {
|
|
164
|
+
'rules' => [
|
|
165
|
+
{
|
|
166
|
+
'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
167
|
+
'value' => {
|
|
168
|
+
'type' => 'provided',
|
|
169
|
+
'value' => { 'source' => 'ENV_VAR', 'lookup' => lookup }
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
},
|
|
174
|
+
environment: nil
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def with_env(name, value)
|
|
179
|
+
original = ENV[name]
|
|
180
|
+
ENV[name] = value
|
|
181
|
+
yield
|
|
182
|
+
ensure
|
|
183
|
+
if original.nil?
|
|
184
|
+
ENV.delete(name)
|
|
185
|
+
else
|
|
186
|
+
ENV[name] = original
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_resolver(cfg)
|
|
191
|
+
store = Quonfig::ConfigStore.new({ cfg[:key] => cfg })
|
|
192
|
+
evaluator = Quonfig::Evaluator.new(store, base_client: base_client)
|
|
193
|
+
Quonfig::Resolver.new(store, evaluator)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def test_resolver_get_resolves_provided_env_var_as_string
|
|
197
|
+
cfg = make_provided_config(key: 'a.string', value_type: 'string', lookup: 'QFG_TEST_STRING')
|
|
198
|
+
resolver = build_resolver(cfg)
|
|
199
|
+
|
|
200
|
+
with_env('QFG_TEST_STRING', 'hello') do
|
|
201
|
+
result = resolver.get('a.string', Quonfig::Context.new({}))
|
|
202
|
+
assert_equal 'hello', result.unwrapped_value
|
|
203
|
+
assert_equal 'string', result.value_type
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def test_resolver_get_resolves_provided_env_var_as_int
|
|
208
|
+
cfg = make_provided_config(key: 'a.number', value_type: 'int', lookup: 'QFG_TEST_INT')
|
|
209
|
+
resolver = build_resolver(cfg)
|
|
210
|
+
|
|
211
|
+
with_env('QFG_TEST_INT', '1234') do
|
|
212
|
+
result = resolver.get('a.number', Quonfig::Context.new({}))
|
|
213
|
+
assert_equal 1234, result.unwrapped_value
|
|
214
|
+
assert_equal 'int', result.value_type
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test_resolver_get_resolves_provided_env_var_as_double
|
|
219
|
+
cfg = make_provided_config(key: 'a.double', value_type: 'double', lookup: 'QFG_TEST_DOUBLE')
|
|
220
|
+
resolver = build_resolver(cfg)
|
|
221
|
+
|
|
222
|
+
with_env('QFG_TEST_DOUBLE', '3.14') do
|
|
223
|
+
result = resolver.get('a.double', Quonfig::Context.new({}))
|
|
224
|
+
assert_in_delta 3.14, result.unwrapped_value, 0.0001
|
|
225
|
+
assert_equal 'double', result.value_type
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def test_resolver_get_resolves_provided_env_var_as_bool
|
|
230
|
+
cfg = make_provided_config(key: 'a.bool', value_type: 'bool', lookup: 'QFG_TEST_BOOL')
|
|
231
|
+
resolver = build_resolver(cfg)
|
|
232
|
+
|
|
233
|
+
with_env('QFG_TEST_BOOL', 'true') do
|
|
234
|
+
result = resolver.get('a.bool', Quonfig::Context.new({}))
|
|
235
|
+
assert_equal true, result.unwrapped_value
|
|
236
|
+
assert_equal 'bool', result.value_type
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
with_env('QFG_TEST_BOOL', 'no') do
|
|
240
|
+
result = resolver.get('a.bool', Quonfig::Context.new({}))
|
|
241
|
+
assert_equal false, result.unwrapped_value
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_resolver_get_resolves_provided_env_var_as_string_list
|
|
246
|
+
cfg = make_provided_config(key: 'a.list', value_type: 'string_list', lookup: 'QFG_TEST_LIST')
|
|
247
|
+
resolver = build_resolver(cfg)
|
|
248
|
+
|
|
249
|
+
with_env('QFG_TEST_LIST', 'a, b ,c') do
|
|
250
|
+
result = resolver.get('a.list', Quonfig::Context.new({}))
|
|
251
|
+
assert_equal %w[a b c], result.unwrapped_value
|
|
252
|
+
assert_equal 'string_list', result.value_type
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def test_resolver_get_raises_missing_env_var_error_when_unset
|
|
257
|
+
cfg = make_provided_config(key: 'a.missing', value_type: 'string', lookup: 'QFG_DEFINITELY_UNSET')
|
|
258
|
+
resolver = build_resolver(cfg)
|
|
259
|
+
ENV.delete('QFG_DEFINITELY_UNSET')
|
|
260
|
+
|
|
261
|
+
err = assert_raises(Quonfig::Errors::MissingEnvVarError) do
|
|
262
|
+
resolver.get('a.missing', Quonfig::Context.new({}))
|
|
263
|
+
end
|
|
264
|
+
assert_match(/QFG_DEFINITELY_UNSET/, err.message)
|
|
265
|
+
assert_match(/a\.missing/, err.message)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_resolver_get_raises_env_var_parse_error_on_bad_int
|
|
269
|
+
cfg = make_provided_config(key: 'a.number', value_type: 'int', lookup: 'QFG_TEST_BAD_INT')
|
|
270
|
+
resolver = build_resolver(cfg)
|
|
271
|
+
|
|
272
|
+
with_env('QFG_TEST_BAD_INT', 'not_a_number') do
|
|
273
|
+
err = assert_raises(Quonfig::Errors::EnvVarParseError) do
|
|
274
|
+
resolver.get('a.number', Quonfig::Context.new({}))
|
|
275
|
+
end
|
|
276
|
+
assert_match(/a\.number/, err.message)
|
|
277
|
+
assert_match(/not_a_number/, err.message)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def test_resolver_get_raises_env_var_parse_error_on_bad_double
|
|
282
|
+
cfg = make_provided_config(key: 'a.double', value_type: 'double', lookup: 'QFG_TEST_BAD_DOUBLE')
|
|
283
|
+
resolver = build_resolver(cfg)
|
|
284
|
+
|
|
285
|
+
with_env('QFG_TEST_BAD_DOUBLE', 'not_a_number') do
|
|
286
|
+
assert_raises(Quonfig::Errors::EnvVarParseError) do
|
|
287
|
+
resolver.get('a.double', Quonfig::Context.new({}))
|
|
288
|
+
end
|
|
289
|
+
end
|
|
143
290
|
end
|
|
144
291
|
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# Verifies the client-level should_log?(logger_path:, desired_level:, contexts:)
|
|
6
|
+
# API — a Reforge-style convenience built on top of the primitive get() that
|
|
7
|
+
# uses the client's `logger_key` option as the config key and injects the
|
|
8
|
+
# logger path under `quonfig-sdk-logging.key`. Parallels sdk-node's
|
|
9
|
+
# shouldLog({loggerPath}) and sdk-go's ShouldLogPath.
|
|
10
|
+
class TestShouldLog < Minitest::Test
|
|
11
|
+
LOG_LEVEL_KEY = 'log-level.my-app'
|
|
12
|
+
|
|
13
|
+
# Minimal config fixture mirroring what ConfigStore expects: a string
|
|
14
|
+
# config whose rule returns the configured log level.
|
|
15
|
+
def make_log_level_config(key:, level:)
|
|
16
|
+
{
|
|
17
|
+
'id' => '1',
|
|
18
|
+
'key' => key,
|
|
19
|
+
'type' => 'config',
|
|
20
|
+
'valueType' => 'string',
|
|
21
|
+
'sendToClientSdk' => false,
|
|
22
|
+
'default' => {
|
|
23
|
+
'rules' => [
|
|
24
|
+
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
|
|
25
|
+
'value' => { 'type' => 'string', 'value' => level } }
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
'environment' => nil
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def store_with(*configs)
|
|
33
|
+
store = Quonfig::ConfigStore.new
|
|
34
|
+
configs.each { |c| store.set(c['key'], c) }
|
|
35
|
+
store
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def client_with(store, **options)
|
|
39
|
+
Quonfig::Client.new(Quonfig::Options.new(**options), store: store)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ---- logger_key option surface ---------------------------------------
|
|
43
|
+
|
|
44
|
+
def test_logger_key_option_defaults_to_nil
|
|
45
|
+
assert_nil Quonfig::Options.new.logger_key
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_logger_key_option_accepts_value
|
|
49
|
+
opts = Quonfig::Options.new(logger_key: LOG_LEVEL_KEY)
|
|
50
|
+
assert_equal LOG_LEVEL_KEY, opts.logger_key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_client_exposes_logger_key_from_options
|
|
54
|
+
client = client_with(Quonfig::ConfigStore.new, logger_key: LOG_LEVEL_KEY)
|
|
55
|
+
assert_equal LOG_LEVEL_KEY, client.logger_key
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ---- should_log? requires logger_key ---------------------------------
|
|
59
|
+
|
|
60
|
+
def test_should_log_raises_without_logger_key
|
|
61
|
+
client = client_with(Quonfig::ConfigStore.new)
|
|
62
|
+
err = assert_raises(Quonfig::Error) do
|
|
63
|
+
client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
64
|
+
end
|
|
65
|
+
assert_match(/logger_key/, err.message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ---- should_log? gating ----------------------------------------------
|
|
69
|
+
|
|
70
|
+
def test_should_log_true_when_desired_at_or_above_configured
|
|
71
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'info'))
|
|
72
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
73
|
+
|
|
74
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
75
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :warn)
|
|
76
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :error)
|
|
77
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :fatal)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_should_log_false_when_desired_below_configured
|
|
81
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
82
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
83
|
+
|
|
84
|
+
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :trace)
|
|
85
|
+
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :debug)
|
|
86
|
+
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :info)
|
|
87
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :warn)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_should_log_accepts_string_desired_level
|
|
91
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'warn'))
|
|
92
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
93
|
+
|
|
94
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: 'warn')
|
|
95
|
+
assert_equal false, client.should_log?(logger_path: 'MyApp::Foo', desired_level: 'info')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_should_log_returns_true_when_no_config_found
|
|
99
|
+
# Missing config key → log everything (match go/node).
|
|
100
|
+
client = client_with(Quonfig::ConfigStore.new, logger_key: LOG_LEVEL_KEY)
|
|
101
|
+
assert_equal true, client.should_log?(logger_path: 'MyApp::Foo', desired_level: :trace)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ---- context injection -----------------------------------------------
|
|
105
|
+
|
|
106
|
+
# Capture what context reaches get() by injecting a spy client that wraps
|
|
107
|
+
# a real store-backed client.
|
|
108
|
+
class ContextCapturingClient
|
|
109
|
+
attr_reader :captured_contexts
|
|
110
|
+
|
|
111
|
+
def initialize(delegate)
|
|
112
|
+
@delegate = delegate
|
|
113
|
+
@captured_contexts = []
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def logger_key
|
|
117
|
+
@delegate.logger_key
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def get(key, default = Quonfig::NO_DEFAULT_PROVIDED, jit_context = Quonfig::NO_DEFAULT_PROVIDED)
|
|
121
|
+
@captured_contexts << jit_context
|
|
122
|
+
@delegate.get(key, default, jit_context)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def test_should_log_injects_logger_path_under_quonfig_sdk_logging_key
|
|
127
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
128
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
129
|
+
|
|
130
|
+
# Reach into the context that get() sees. We do this by asserting on the
|
|
131
|
+
# resolver via a fake — simplest path: call should_log? with a sentinel
|
|
132
|
+
# path and verify the evaluator would see it. We assert via the public
|
|
133
|
+
# contract: context reaches get(), so we patch get() temporarily.
|
|
134
|
+
captured = []
|
|
135
|
+
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
136
|
+
captured << { key: key, jit_context: jit_context }
|
|
137
|
+
'trace'
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
client.should_log?(logger_path: 'MyApp::Services::Auth', desired_level: :info)
|
|
141
|
+
|
|
142
|
+
assert_equal 1, captured.size
|
|
143
|
+
assert_equal LOG_LEVEL_KEY, captured.first[:key]
|
|
144
|
+
ctx = captured.first[:jit_context]
|
|
145
|
+
assert_equal({ 'quonfig-sdk-logging' => { 'key' => 'MyApp::Services::Auth' } }, ctx)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def test_should_log_merges_caller_contexts_with_logger_context
|
|
149
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
150
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
151
|
+
|
|
152
|
+
captured = []
|
|
153
|
+
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
154
|
+
captured << jit_context
|
|
155
|
+
'trace'
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
client.should_log?(
|
|
159
|
+
logger_path: 'MyApp::Foo',
|
|
160
|
+
desired_level: :info,
|
|
161
|
+
contexts: { 'user' => { 'id' => 'u1' } }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
assert_equal(
|
|
165
|
+
{
|
|
166
|
+
'user' => { 'id' => 'u1' },
|
|
167
|
+
'quonfig-sdk-logging' => { 'key' => 'MyApp::Foo' }
|
|
168
|
+
},
|
|
169
|
+
captured.first
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def test_should_log_logger_path_verbatim_no_normalization
|
|
174
|
+
store = store_with(make_log_level_config(key: LOG_LEVEL_KEY, level: 'trace'))
|
|
175
|
+
client = client_with(store, logger_key: LOG_LEVEL_KEY)
|
|
176
|
+
|
|
177
|
+
captured = []
|
|
178
|
+
client.define_singleton_method(:get) do |key, default = nil, jit_context = nil|
|
|
179
|
+
captured << jit_context
|
|
180
|
+
'trace'
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
client.should_log?(logger_path: 'HTMLParser', desired_level: :info)
|
|
184
|
+
assert_equal 'HTMLParser', captured.first['quonfig-sdk-logging']['key']
|
|
185
|
+
end
|
|
186
|
+
end
|