quonfig 0.0.6 → 0.0.9
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/bound_client.rb +26 -0
- data/lib/quonfig/client.rb +212 -3
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/dev_context.rb +41 -0
- 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 +84 -3
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/options.rb +4 -1
- data/lib/quonfig/resolver.rb +215 -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 +212 -0
- data/lib/quonfig.rb +10 -0
- data/quonfig.gemspec +23 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_dev_overrides.rb +40 -0
- 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 +532 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +77 -21
- data/test/test_client_telemetry.rb +175 -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_dev_context.rb +163 -0
- 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 +22 -3
- data/scripts/generate_integration_tests.rb +0 -362
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
|
|
8
|
+
# qfg-pj0.5 — Dev-context injection. When enable_quonfig_user_context: true
|
|
9
|
+
# (or env var QUONFIG_DEV_CONTEXT=true), the SDK reads ~/.quonfig/tokens.json
|
|
10
|
+
# (written by `qfg login`) and merges {'quonfig-user' => {'email' => ...}}
|
|
11
|
+
# into globalContext. Customer-supplied keys win on collision.
|
|
12
|
+
#
|
|
13
|
+
# Mirror of sdk-node qfg-pj0.3 / sdk-go qfg-pj0.4.
|
|
14
|
+
class TestDevContext < Minitest::Test
|
|
15
|
+
def setup
|
|
16
|
+
super
|
|
17
|
+
@tmphome = Dir.mktmpdir('quonfig-dev-ctx-')
|
|
18
|
+
FileUtils.mkdir_p(File.join(@tmphome, '.quonfig'))
|
|
19
|
+
@old_home = ENV.fetch('HOME', nil)
|
|
20
|
+
ENV['HOME'] = @tmphome
|
|
21
|
+
ENV.delete('QUONFIG_DEV_CONTEXT')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def teardown
|
|
25
|
+
ENV['HOME'] = @old_home
|
|
26
|
+
ENV.delete('QUONFIG_DEV_CONTEXT')
|
|
27
|
+
FileUtils.remove_entry(@tmphome) if @tmphome && Dir.exist?(@tmphome)
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_tokens(payload)
|
|
32
|
+
File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), JSON.generate(payload))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def global_context_of(client)
|
|
36
|
+
client.instance_variable_get(:@global_context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 1. RED: injects quonfig-user.email when option enabled and file exists
|
|
40
|
+
def test_injects_quonfig_user_email_when_option_enabled
|
|
41
|
+
write_tokens(userEmail: 'bob@foo.com', accessToken: 'x', refreshToken: 'y', expiresAt: 0)
|
|
42
|
+
|
|
43
|
+
client = Quonfig::Client.new(
|
|
44
|
+
Quonfig::Options.new(enable_quonfig_user_context: true),
|
|
45
|
+
store: Quonfig::ConfigStore.new
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 2. RED: no-op when option disabled and no env var
|
|
52
|
+
def test_no_op_when_option_disabled
|
|
53
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
54
|
+
|
|
55
|
+
client = Quonfig::Client.new(
|
|
56
|
+
Quonfig::Options.new(global_context: { user: { 'plan' => 'pro' } }),
|
|
57
|
+
store: Quonfig::ConfigStore.new
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 3. RED: no-op when option enabled but file missing
|
|
64
|
+
def test_no_op_when_file_missing
|
|
65
|
+
# No tokens.json written.
|
|
66
|
+
client = Quonfig::Client.new(
|
|
67
|
+
Quonfig::Options.new(
|
|
68
|
+
enable_quonfig_user_context: true,
|
|
69
|
+
global_context: { user: { 'plan' => 'pro' } }
|
|
70
|
+
),
|
|
71
|
+
store: Quonfig::ConfigStore.new
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 4. RED: no-op when file unparseable; warning emitted; init succeeds
|
|
78
|
+
def test_no_op_when_file_unparseable
|
|
79
|
+
File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), '{not valid json')
|
|
80
|
+
|
|
81
|
+
client = Quonfig::Client.new(
|
|
82
|
+
Quonfig::Options.new(enable_quonfig_user_context: true),
|
|
83
|
+
store: Quonfig::ConfigStore.new
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert_equal({}, global_context_of(client))
|
|
87
|
+
# The dev-context loader emits a warning to stderr that we want to verify.
|
|
88
|
+
assert_stderr(['quonfig'])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# 5. RED: customer-supplied quonfig-user keys win on collision
|
|
92
|
+
def test_customer_global_context_wins
|
|
93
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
94
|
+
|
|
95
|
+
client = Quonfig::Client.new(
|
|
96
|
+
Quonfig::Options.new(
|
|
97
|
+
enable_quonfig_user_context: true,
|
|
98
|
+
global_context: { 'quonfig-user' => { 'email' => 'override@x.com' } }
|
|
99
|
+
),
|
|
100
|
+
store: Quonfig::ConfigStore.new
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'override@x.com' } }, global_context_of(client))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 6. RED: env var QUONFIG_DEV_CONTEXT=true enables when option absent
|
|
107
|
+
def test_env_var_enables_when_option_absent
|
|
108
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
109
|
+
ENV['QUONFIG_DEV_CONTEXT'] = 'true'
|
|
110
|
+
|
|
111
|
+
client = Quonfig::Client.new(
|
|
112
|
+
Quonfig::Options.new,
|
|
113
|
+
store: Quonfig::ConfigStore.new
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# 7. RED: integration — rule keyed on quonfig-user.email fires when injected
|
|
120
|
+
def test_attribute_reaches_eval_context
|
|
121
|
+
write_tokens(userEmail: 'bob@foo.com')
|
|
122
|
+
|
|
123
|
+
flag_config = {
|
|
124
|
+
'id' => 'cfg-flag',
|
|
125
|
+
'key' => 'my-flag',
|
|
126
|
+
'type' => 'feature_flag',
|
|
127
|
+
'valueType' => 'bool',
|
|
128
|
+
'sendToClientSdk' => false,
|
|
129
|
+
'default' => {
|
|
130
|
+
'rules' => [
|
|
131
|
+
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
'environment' => {
|
|
135
|
+
'id' => 'Production',
|
|
136
|
+
'rules' => [
|
|
137
|
+
{
|
|
138
|
+
'criteria' => [{
|
|
139
|
+
'propertyName' => 'quonfig-user.email',
|
|
140
|
+
'operator' => 'PROP_IS_ONE_OF',
|
|
141
|
+
'valueToMatch' => { 'type' => 'string_list', 'value' => ['bob@foo.com'] }
|
|
142
|
+
}],
|
|
143
|
+
'value' => { 'type' => 'bool', 'value' => true }
|
|
144
|
+
},
|
|
145
|
+
{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
|
|
146
|
+
]
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
store = Quonfig::ConfigStore.new
|
|
151
|
+
store.set('my-flag', flag_config)
|
|
152
|
+
|
|
153
|
+
client = Quonfig::Client.new(
|
|
154
|
+
Quonfig::Options.new(
|
|
155
|
+
enable_quonfig_user_context: true,
|
|
156
|
+
environment: 'Production'
|
|
157
|
+
),
|
|
158
|
+
store: store
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert_equal true, client.get_bool('my-flag')
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -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
|