quonfig 0.0.8 → 0.0.10

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.
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # AUTO-GENERATED from integration-test-data/tests/eval/dev_overrides.yaml.
4
+ # Regenerate with:
5
+ # cd integration-test-data/generators && npm run generate -- --target=ruby
6
+ # Source: integration-test-data/generators/src/targets/ruby.ts
7
+ # Do NOT edit by hand — changes will be overwritten.
8
+
9
+ require 'test_helper'
10
+ require 'integration/test_helpers'
11
+
12
+ class TestDevOverrides < Minitest::Test
13
+ def setup
14
+ @store = IntegrationTestHelpers.build_store("dev_overrides")
15
+ end
16
+
17
+ # override fires when quonfig-user.email matches
18
+ def test_override_fires_when_quonfig_user_email_matches
19
+ resolver = IntegrationTestHelpers.build_resolver(@store)
20
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"quonfig-user" => {"email" => "bob@foo.com"}}, true)
21
+ end
22
+
23
+ # override does not fire when attribute absent (prod simulation)
24
+ def test_override_does_not_fire_when_attribute_absent_prod_simulation
25
+ resolver = IntegrationTestHelpers.build_resolver(@store)
26
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override", {"user" => {"email" => "bob@foo.com"}}, false)
27
+ end
28
+
29
+ # override matches any email in IS_ONE_OF list
30
+ def test_override_matches_any_email_in_is_one_of_list
31
+ resolver = IntegrationTestHelpers.build_resolver(@store)
32
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.multi-email", {"quonfig-user" => {"email" => "alice@foo.com"}}, true)
33
+ end
34
+
35
+ # override beats customer rule by priority
36
+ def test_override_beats_customer_rule_by_priority
37
+ resolver = IntegrationTestHelpers.build_resolver(@store)
38
+ IntegrationTestHelpers.assert_enabled(resolver, "feature-flag.dev-override.priority", {"quonfig-user" => {"email" => "bob@foo.com"}, "user" => {"country" => "DE"}}, true)
39
+ end
40
+ end
@@ -290,6 +290,13 @@ module IntegrationTestHelpers
290
290
  # so eval-summary cases can resolve real values for each key.
291
291
  class << self
292
292
  attr_accessor :last_store
293
+ # Side-channel populated by record_one_eval whenever we redact a
294
+ # confidential value before recording it on the aggregator. Keyed by
295
+ # config_key → { unwrapped:, value_type: }. evaluation_summary_post
296
+ # consults it so the YAML's `value` / `value_type` fields can still
297
+ # assert the runtime resolved value while `selected_value` carries
298
+ # the wire-redacted form.
299
+ attr_accessor :last_unwrapped_overrides
293
300
  end
294
301
 
295
302
  # Construct an aggregator. +kind+ is one of :context_shape,
@@ -465,6 +472,7 @@ module IntegrationTestHelpers
465
472
  ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
466
473
  empty_ctx = Quonfig::Context.new({})
467
474
 
475
+ self.last_unwrapped_overrides = {}
468
476
  Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
469
477
  Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
470
478
  end
@@ -482,13 +490,28 @@ module IntegrationTestHelpers
482
490
  end
483
491
  return if result.nil?
484
492
 
493
+ # Confidential / decryptWith values must never appear in plaintext on
494
+ # the wire. EvalResult#reportable_value, when populated, is the
495
+ # `*****<md5>`-redacted substitute the resolver computed pre-decryption.
496
+ # When we substitute, stash the runtime unwrapped value so the
497
+ # post-projection can still assert YAML's `value` / `value_type` against
498
+ # the resolved plaintext (the YAML treats `value` as the runtime view
499
+ # and `selected_value` as the wire view).
500
+ selected_for_telemetry = result.unwrapped_value
501
+ if result.reportable_value
502
+ selected_for_telemetry = result.reportable_value
503
+ (self.last_unwrapped_overrides ||= {})[key] = {
504
+ unwrapped: result.unwrapped_value,
505
+ value_type: result.value_type
506
+ }
507
+ end
485
508
  aggregator.record(
486
509
  config_id: (cfg[:id] || cfg['id']).to_s,
487
510
  config_key: key,
488
511
  config_type: (cfg[:type] || cfg['type']).to_s,
489
512
  conditional_value_index: result.rule_index,
490
513
  weighted_value_index: result.weighted_value_index,
491
- selected_value: result.unwrapped_value,
514
+ selected_value: selected_for_telemetry,
492
515
  reason: result.wire_reason
493
516
  )
494
517
  end
@@ -545,6 +568,7 @@ module IntegrationTestHelpers
545
568
 
546
569
  def self.evaluation_summary_post(event)
547
570
  summaries = event.dig('summaries', 'summaries') || []
571
+ overrides = last_unwrapped_overrides || {}
548
572
  rows = []
549
573
  summaries.each do |summary|
550
574
  type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
@@ -552,6 +576,15 @@ module IntegrationTestHelpers
552
576
  counters.each do |counter|
553
577
  selected = counter['selectedValue'] || {}
554
578
  unwrapped, value_type = unwrap_selected(selected)
579
+ # When the resolver redacted this key (confidential / decryptWith),
580
+ # selected_value carries the redacted form on the wire but YAML's
581
+ # `value` / `value_type` should still reflect the runtime resolved
582
+ # plaintext. Restore from the side channel populated in
583
+ # record_one_eval.
584
+ if (override = overrides[summary['key']])
585
+ unwrapped = override[:unwrapped]
586
+ value_type = override[:value_type] if override[:value_type]
587
+ end
555
588
  row = {
556
589
  'key' => summary['key'],
557
590
  'type' => type_label,
@@ -153,4 +153,18 @@ class TestTelemetry < Minitest::Test
153
153
  IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {}, contexts: {})
154
154
  IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, nil, endpoint: "/api/v1/context-shapes")
155
155
  end
156
+
157
+ # confidential plain string is redacted in selectedValue
158
+ def test_confidential_plain_string_is_redacted_in_selectedvalue
159
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
160
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["confidential.new.string"]}, contexts: {})
161
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "confidential.new.string", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****18aa7"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
162
+ end
163
+
164
+ # confidential encrypted string is redacted using ciphertext hash
165
+ def test_confidential_encrypted_string_is_redacted_using_ciphertext_hash
166
+ aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
167
+ IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["a.secret.config"]}, contexts: {})
168
+ IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "a.secret.config", "type" => "CONFIG", "value" => "hello.world", "value_type" => "string", "count" => 1, "reason" => 1, "selected_value" => {"string" => "*****936c9"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}], endpoint: "/api/v1/telemetry")
169
+ end
156
170
  end
@@ -129,4 +129,47 @@ class TestClientTelemetry < Minitest::Test
129
129
  client.get('does.not.exist', 'fallback')
130
130
  assert_nil summaries_agg.drain_event
131
131
  end
132
+
133
+ # Regression: client used to hardcode weighted_value_index: nil, so split
134
+ # variants never reported as REASON_SPLIT in telemetry.
135
+ def test_weighted_value_records_split_reason_and_index
136
+ weighted_config = {
137
+ 'id' => 'cid-weighted',
138
+ 'key' => 'feature-flag.weighted',
139
+ 'type' => 'feature_flag',
140
+ 'valueType' => 'string',
141
+ 'sendToClientSdk' => false,
142
+ 'default' => {
143
+ 'rules' => [
144
+ {
145
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
146
+ 'value' => {
147
+ 'type' => 'weighted_values',
148
+ 'value' => {
149
+ 'hashByPropertyName' => 'user.key',
150
+ 'weightedValues' => [
151
+ { 'value' => { 'type' => 'string', 'value' => 'control' }, 'weight' => 1 },
152
+ { 'value' => { 'type' => 'string', 'value' => 'variant' }, 'weight' => 99 }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+ ]
158
+ },
159
+ 'environment' => nil
160
+ }
161
+
162
+ store = Quonfig::ConfigStore.new
163
+ store.set('feature-flag.weighted', weighted_config)
164
+
165
+ client, _reporter, summaries_agg, _conn = make_client_with_telemetry(store)
166
+ client.get('feature-flag.weighted', Quonfig::NO_DEFAULT_PROVIDED, 'user' => { 'key' => 'u1' })
167
+
168
+ counter = summaries_agg.drain_event['summaries']['summaries'][0]['counters'][0]
169
+ assert_equal Quonfig::EvalResult::REASON_SPLIT, counter['reason'],
170
+ 'weighted variant evaluation must report REASON_SPLIT (3)'
171
+ refute_nil counter['weightedValueIndex'],
172
+ 'weighted variant evaluation must report a weightedValueIndex'
173
+ assert_kind_of Integer, counter['weightedValueIndex']
174
+ end
132
175
  end
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'test_helper'
4
+
5
+ # Coverage for the *_details getters on Quonfig::Client and Quonfig::BoundClient.
6
+ # These return Quonfig::EvaluationDetails carrying an OpenFeature-aligned
7
+ # +reason+ and (on the error path) error_code / error_message. They never raise.
8
+ class TestDetailsGetters < Minitest::Test
9
+ Details = Quonfig::EvaluationDetails
10
+
11
+ INTEGRATION_FIXTURE_DIR = File.expand_path(
12
+ '../../integration-test-data/data/integration-tests', __dir__
13
+ )
14
+
15
+ # ---- Helpers --------------------------------------------------------
16
+
17
+ def fixture_client
18
+ skip "integration-test-data sibling missing at #{INTEGRATION_FIXTURE_DIR}" unless Dir.exist?(INTEGRATION_FIXTURE_DIR)
19
+
20
+ Quonfig::Client.new(
21
+ Quonfig::Options.new(
22
+ datadir: INTEGRATION_FIXTURE_DIR,
23
+ environment: 'Production',
24
+ enable_sse: false,
25
+ enable_polling: false
26
+ )
27
+ )
28
+ end
29
+
30
+ # An ALWAYS_TRUE-only config: no targeting rules anywhere -> STATIC.
31
+ def make_static_config(key:, value:, type:)
32
+ {
33
+ 'id' => '1',
34
+ 'key' => key,
35
+ 'type' => 'config',
36
+ 'valueType' => type,
37
+ 'sendToClientSdk' => false,
38
+ 'default' => {
39
+ 'rules' => [
40
+ {
41
+ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }],
42
+ 'value' => { 'type' => type, 'value' => value }
43
+ }
44
+ ]
45
+ },
46
+ 'environment' => nil
47
+ }
48
+ end
49
+
50
+ def static_client(key:, value:, type:)
51
+ store = Quonfig::ConfigStore.new
52
+ store.set(key, make_static_config(key: key, value: value, type: type))
53
+ Quonfig::Client.new(Quonfig::Options.new, store: store)
54
+ end
55
+
56
+ # ---- STATIC reason --------------------------------------------------
57
+
58
+ def test_get_bool_details_static_reason_for_always_true_only_config
59
+ client = static_client(key: 'plain', value: true, type: 'bool')
60
+ details = client.get_bool_details('plain')
61
+ assert_kind_of Details, details
62
+ assert_equal true, details.value
63
+ assert_equal Details::REASON_STATIC, details.reason
64
+ assert_nil details.error_code
65
+ assert_nil details.error_message
66
+ end
67
+
68
+ def test_get_string_details_static_reason
69
+ client = static_client(key: 'plain.s', value: 'hi', type: 'string')
70
+ details = client.get_string_details('plain.s')
71
+ assert_equal 'hi', details.value
72
+ assert_equal Details::REASON_STATIC, details.reason
73
+ end
74
+
75
+ def test_static_reason_via_integration_fixture_always_true
76
+ client = fixture_client
77
+ details = client.get_bool_details('always.true')
78
+ assert_equal true, details.value
79
+ assert_equal Details::REASON_STATIC, details.reason
80
+ ensure
81
+ client&.stop
82
+ end
83
+
84
+ # ---- TARGETING_MATCH reason ----------------------------------------
85
+
86
+ def test_targeting_match_reason_via_integration_fixture
87
+ client = fixture_client
88
+ details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'pro' } })
89
+ assert_equal true, details.value
90
+ assert_equal Details::REASON_TARGETING_MATCH, details.reason
91
+ ensure
92
+ client&.stop
93
+ end
94
+
95
+ def test_targeting_match_falls_through_to_default_branch_still_targeting
96
+ # The of.targeting fixture has a property-match rule + an ALWAYS_TRUE
97
+ # fallback. Even when the fallback wins, the *config* has targeting rules,
98
+ # so wire_reason / of_reason returns TARGETING_MATCH.
99
+ client = fixture_client
100
+ details = client.get_bool_details('of.targeting', context: { 'user' => { 'plan' => 'free' } })
101
+ assert_equal false, details.value
102
+ assert_equal Details::REASON_TARGETING_MATCH, details.reason
103
+ ensure
104
+ client&.stop
105
+ end
106
+
107
+ # ---- SPLIT reason ---------------------------------------------------
108
+
109
+ def test_split_reason_via_integration_fixture
110
+ client = fixture_client
111
+ # Pick a deterministic targetingKey so the outcome is reproducible.
112
+ details = client.get_string_details('of.weighted', context: { 'user' => { 'id' => 'user-123' } })
113
+ assert_equal Details::REASON_SPLIT, details.reason
114
+ assert_includes %w[variant-a variant-b], details.value
115
+ ensure
116
+ client&.stop
117
+ end
118
+
119
+ # ---- DEFAULT reason -------------------------------------------------
120
+
121
+ def test_default_reason_when_no_rule_matches
122
+ # Config exists but no rule matches against the empty context.
123
+ config = {
124
+ 'id' => '9',
125
+ 'key' => 'no.match.here',
126
+ 'type' => 'config',
127
+ 'valueType' => 'string',
128
+ 'sendToClientSdk' => false,
129
+ 'default' => {
130
+ 'rules' => [
131
+ {
132
+ 'criteria' => [
133
+ {
134
+ 'propertyName' => 'user.plan',
135
+ 'operator' => 'PROP_IS_ONE_OF',
136
+ 'valueToMatch' => { 'type' => 'string_list', 'value' => ['enterprise'] }
137
+ }
138
+ ],
139
+ 'value' => { 'type' => 'string', 'value' => 'gold' }
140
+ }
141
+ ]
142
+ },
143
+ 'environment' => nil
144
+ }
145
+ store = Quonfig::ConfigStore.new
146
+ store.set('no.match.here', config)
147
+ client = Quonfig::Client.new(Quonfig::Options.new, store: store)
148
+
149
+ details = client.get_string_details('no.match.here')
150
+ assert_nil details.value
151
+ assert_equal Details::REASON_DEFAULT, details.reason
152
+ assert_nil details.error_code
153
+ end
154
+
155
+ # ---- ERROR / FLAG_NOT_FOUND ----------------------------------------
156
+
157
+ def test_flag_not_found_returns_error_details
158
+ client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
159
+ details = client.get_bool_details('does.not.exist')
160
+ assert_nil details.value
161
+ assert_equal Details::REASON_ERROR, details.reason
162
+ assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
163
+ refute_nil details.error_message
164
+ end
165
+
166
+ def test_flag_not_found_for_string_details
167
+ client = Quonfig::Client.new(Quonfig::Options.new, store: Quonfig::ConfigStore.new)
168
+ details = client.get_string_details('nope')
169
+ assert_equal Details::REASON_ERROR, details.reason
170
+ assert_equal Details::ERROR_FLAG_NOT_FOUND, details.error_code
171
+ end
172
+
173
+ # ---- ERROR / TYPE_MISMATCH -----------------------------------------
174
+
175
+ def test_type_mismatch_for_int_when_value_is_string
176
+ client = static_client(key: 'wrong.type', value: 'oops', type: 'string')
177
+ details = client.get_int_details('wrong.type')
178
+ assert_nil details.value
179
+ assert_equal Details::REASON_ERROR, details.reason
180
+ assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
181
+ refute_nil details.error_message
182
+ end
183
+
184
+ def test_type_mismatch_for_bool_when_value_is_string
185
+ client = static_client(key: 'bool.miss', value: 'true', type: 'string')
186
+ details = client.get_bool_details('bool.miss')
187
+ assert_equal Details::REASON_ERROR, details.reason
188
+ assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
189
+ end
190
+
191
+ def test_type_mismatch_for_string_list_when_value_is_string
192
+ client = static_client(key: 'list.miss', value: 'a,b', type: 'string')
193
+ details = client.get_string_list_details('list.miss')
194
+ assert_equal Details::REASON_ERROR, details.reason
195
+ assert_equal Details::ERROR_TYPE_MISMATCH, details.error_code
196
+ end
197
+
198
+ # ---- json passes through unchanged ---------------------------------
199
+
200
+ def test_get_json_details_returns_hash_with_static_reason
201
+ payload = { 'a' => 1, 'b' => [1, 2] }
202
+ client = static_client(key: 'shape', value: payload, type: 'json')
203
+ details = client.get_json_details('shape')
204
+ assert_equal payload, details.value
205
+ assert_equal Details::REASON_STATIC, details.reason
206
+ end
207
+
208
+ # ---- Float / Int success path -------------------------------------
209
+
210
+ def test_get_int_details_static_reason
211
+ client = static_client(key: 'i', value: 42, type: 'int')
212
+ details = client.get_int_details('i')
213
+ assert_equal 42, details.value
214
+ assert_equal Details::REASON_STATIC, details.reason
215
+ end
216
+
217
+ def test_get_float_details_static_reason
218
+ client = static_client(key: 'f', value: 3.14, type: 'double')
219
+ details = client.get_float_details('f')
220
+ assert_in_delta 3.14, details.value, 1e-9
221
+ assert_equal Details::REASON_STATIC, details.reason
222
+ end
223
+
224
+ def test_get_string_list_details_static_reason
225
+ client = static_client(key: 'sl', value: %w[a b], type: 'string_list')
226
+ details = client.get_string_list_details('sl')
227
+ assert_equal %w[a b], details.value
228
+ assert_equal Details::REASON_STATIC, details.reason
229
+ end
230
+
231
+ # ---- BoundClient mirror --------------------------------------------
232
+
233
+ def test_bound_client_get_bool_details_passes_context
234
+ client = fixture_client
235
+ bound = client.in_context('user' => { 'plan' => 'pro' })
236
+ details = bound.get_bool_details('of.targeting')
237
+ assert_equal true, details.value
238
+ assert_equal Details::REASON_TARGETING_MATCH, details.reason
239
+ ensure
240
+ client&.stop
241
+ end
242
+ end
@@ -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
@@ -65,15 +65,17 @@ module Quonfig
65
65
  refute(Options::DEFAULT_API_URLS.any? { |s| s.include?('reforge.com') })
66
66
  end
67
67
 
68
- def test_telemetry_destination_honors_quonfig_telemetry_url_env
69
- with_env('QUONFIG_TELEMETRY_URL', 'https://override-telemetry.example.com') do
70
- assert_equal 'https://override-telemetry.example.com',
68
+ def test_telemetry_destination_honors_quonfig_domain_env
69
+ with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
70
+ assert_equal 'https://telemetry.quonfig-staging.com',
71
71
  Options.new.telemetry_destination
72
72
  end
73
73
  end
74
74
 
75
75
  def test_telemetry_destination_default
76
- assert_equal 'https://telemetry.quonfig.com', Options.new.telemetry_destination
76
+ with_env('QUONFIG_DOMAIN', nil) do
77
+ assert_equal 'https://telemetry.quonfig.com', Options.new.telemetry_destination
78
+ end
77
79
  end
78
80
  end
79
81
  end