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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/bound_client.rb +26 -0
  5. data/lib/quonfig/client.rb +212 -3
  6. data/lib/quonfig/context.rb +10 -1
  7. data/lib/quonfig/datadir.rb +2 -4
  8. data/lib/quonfig/dev_context.rb +41 -0
  9. data/lib/quonfig/errors/decryption_error.rb +20 -0
  10. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  11. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  12. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  13. data/lib/quonfig/evaluator.rb +84 -3
  14. data/lib/quonfig/http_connection.rb +1 -1
  15. data/lib/quonfig/options.rb +4 -1
  16. data/lib/quonfig/resolver.rb +215 -2
  17. data/lib/quonfig/stdlib_formatter.rb +95 -0
  18. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  19. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  20. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  21. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  22. data/lib/quonfig/telemetry/telemetry_reporter.rb +212 -0
  23. data/lib/quonfig.rb +10 -0
  24. data/quonfig.gemspec +23 -4
  25. data/test/integration/test_context_precedence.rb +35 -117
  26. data/test/integration/test_datadir_environment.rb +15 -37
  27. data/test/integration/test_dev_overrides.rb +40 -0
  28. data/test/integration/test_enabled.rb +157 -463
  29. data/test/integration/test_enabled_with_contexts.rb +19 -49
  30. data/test/integration/test_get.rb +43 -131
  31. data/test/integration/test_get_feature_flag.rb +7 -13
  32. data/test/integration/test_get_or_raise.rb +19 -45
  33. data/test/integration/test_get_weighted_values.rb +9 -4
  34. data/test/integration/test_helpers.rb +532 -4
  35. data/test/integration/test_post.rb +15 -5
  36. data/test/integration/test_telemetry.rb +77 -21
  37. data/test/test_client_telemetry.rb +175 -0
  38. data/test/test_context.rb +4 -1
  39. data/test/test_context_shape.rb +37 -0
  40. data/test/test_context_shape_aggregator.rb +126 -0
  41. data/test/test_datadir.rb +6 -2
  42. data/test/test_dev_context.rb +163 -0
  43. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  44. data/test/test_example_contexts_aggregator.rb +119 -0
  45. data/test/test_http_connection.rb +1 -1
  46. data/test/test_resolver.rb +149 -2
  47. data/test/test_should_log.rb +186 -0
  48. data/test/test_stdlib_formatter.rb +195 -0
  49. data/test/test_telemetry_reporter.rb +209 -0
  50. metadata +22 -3
  51. 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 'ruby-0.1.0', conn.headers['X-Quonfig-SDK-Version']
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
@@ -134,11 +134,158 @@ class TestResolverTrio < Minitest::Test
134
134
  assert_equal DEFAULT_VALUE, result.unwrapped_value
135
135
  end
136
136
 
137
- def test_resolver_get_returns_nil_for_missing_key
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
- assert_nil resolver.get('nope', Quonfig::Context.new({}))
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