quonfig 0.0.9 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2dfc7385d529233f3b0b7c5fc13dbe7d0d648851ea0815bde792465a1774319
4
- data.tar.gz: f6c702ba4eccdebeda95b8f9341fc0bb077a2c141c9397cb7f3dccb1cbfa782a
3
+ metadata.gz: 94866b65b3e3c4e834897981847da01f350984b2f7126f8259318ba385b8fd77
4
+ data.tar.gz: bc173a4300c1475f596921de2a2d24e02e5de648503318491a602c19123dd329
5
5
  SHA512:
6
- metadata.gz: 368273026e247c7c01df6e85f11865f94bb588d4c4e512bbc44de65dda0a7659b96a8bd321de6ea676934b9483b0dd99d0fe516d3b288cf93a709f401bbfd824
7
- data.tar.gz: be80686fa0c8748c4e1c3f802cfc51c605b1a3e31ec7f9682885a139b5081a79d91d9155833143671bfffa75728bf1091116456defd90bd610d17a5133287e03
6
+ metadata.gz: 875265c218e5a465abced6d47563ef7c86ade61ca2d98367059bc800dec55e1148badf58e60b632e468ab565ef5ec80e587b8f0fd045f5300b000f64fe12b909
7
+ data.tar.gz: 6725f7b13ac6ba43d0e7903963c5de4b1bbe03f1ae7e51399e4d0cc8877f6ee637ef2850e396e65c8d5c44cac2ee7cfa0b2f1724d390d43746d421d7e465c42a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.10 - 2026-05-01
4
+
5
+ - **BREAKING (env): `QUONFIG_TELEMETRY_URL` and `QUONFIG_API_URLS` env vars
6
+ removed.** Replaced by a single `QUONFIG_DOMAIN` env var (default
7
+ `quonfig.com`) that derives api, sse, and telemetry URLs uniformly. e.g.
8
+ `QUONFIG_DOMAIN=quonfig-staging.com` → `https://primary.quonfig-staging.com`,
9
+ `https://stream.primary.quonfig-staging.com`,
10
+ `https://telemetry.quonfig-staging.com`. Mirrors the CLI's
11
+ `domain-urls.ts` convention and matches sdk-go / sdk-node. Resolution order
12
+ (highest wins): explicit `api_urls:` / `telemetry_url:` kwargs >
13
+ `QUONFIG_DOMAIN` > hardcoded default. Fixes qfg-w6gg, where the prior
14
+ primary-prefix regex silently fell through to prod telemetry on staging
15
+ hosts. The new `Quonfig::Options#init` also accepts an explicit
16
+ `telemetry_url:` kwarg (was previously documented but not wired up).
17
+ - **Default `api_urls` now includes secondary.** Was `[primary]`, now
18
+ `[primary, secondary]` to match every other SDK and provide failover.
19
+
3
20
  ## 0.0.8 - 2026-04-26
4
21
 
5
22
  - **Fix (gemspec): drop deleted `scripts/` entry from manifest** — regenerated
data/CLAUDE.md CHANGED
@@ -26,4 +26,4 @@ integration suite cannot resolve its YAML specs.
26
26
  - `QUONFIG_BACKEND_SDK_KEY` — backend SDK key for authenticated config delivery
27
27
  - `QUONFIG_DIR` — path to a local Quonfig workspace (datadir mode)
28
28
  - `QUONFIG_ENVIRONMENT` — which environment to evaluate (`production`, `staging`, `development`)
29
- - `QUONFIG_TELEMETRY_URL` — telemetry ingest endpoint
29
+ - `QUONFIG_DOMAIN` — base domain used to derive api/sse/telemetry URLs (default `quonfig.com`). Setting `QUONFIG_DOMAIN=quonfig-staging.com` derives `https://primary.quonfig-staging.com`, `https://stream.primary.quonfig-staging.com`, and `https://telemetry.quonfig-staging.com` automatically. Explicit `api_urls:` / `telemetry_url:` kwargs override this.
data/README.md CHANGED
@@ -114,14 +114,14 @@ client = Quonfig::Client.new # reads QUONFIG_DIR + QUONFIG_ENVIRONMENT
114
114
  | `QUONFIG_BACKEND_SDK_KEY` | SDK key used to authenticate against the Quonfig API. Used when `sdk_key:` is omitted. |
115
115
  | `QUONFIG_DIR` | Path to a workspace directory. When set, the SDK runs in datadir/offline mode. |
116
116
  | `QUONFIG_ENVIRONMENT` | Environment name (`production`, `staging`, `development`) evaluated in datadir mode. |
117
- | `QUONFIG_TELEMETRY_URL` | Overrides the telemetry endpoint. Defaults to `https://telemetry.quonfig.com`. |
117
+ | `QUONFIG_DOMAIN` | Base domain used to derive api, sse, and telemetry URLs. Defaults to `quonfig.com`. Set to `quonfig-staging.com` to point at staging. Explicit `api_urls:` / `telemetry_url:` kwargs override this. |
118
118
 
119
119
  ## Constructor options
120
120
 
121
121
  ```ruby
122
122
  Quonfig::Client.new(
123
123
  sdk_key: '...', # required unless QUONFIG_BACKEND_SDK_KEY is set
124
- api_urls: ['https://primary.quonfig.com'],
124
+ api_urls: ['https://primary.quonfig.com', 'https://secondary.quonfig.com'],
125
125
  telemetry_url: 'https://telemetry.quonfig.com',
126
126
  enable_sse: true,
127
127
  enable_polling: false,
@@ -137,8 +137,8 @@ Quonfig::Client.new(
137
137
  | Option | Type | Default | Description |
138
138
  |-------------------|----------------------------|---------------------------------------------------------------------|---------------------------------------------------------------------------------------------------|
139
139
  | `sdk_key` | `String` | `ENV['QUONFIG_BACKEND_SDK_KEY']` | SDK key for API authentication. |
140
- | `api_urls` | `Array<String>` | `['https://primary.quonfig.com']` | Ordered list of API base URLs to try. SSE stream URLs are derived by prepending `stream.` to each hostname. |
141
- | `telemetry_url` | `String` | `https://telemetry.quonfig.com` (or `ENV['QUONFIG_TELEMETRY_URL']`) | Base URL for the telemetry service. |
140
+ | `api_urls` | `Array<String>` | `["https://primary.${QUONFIG_DOMAIN}", "https://secondary.${QUONFIG_DOMAIN}"]` | Ordered list of API base URLs to try. SSE stream URLs are derived by prepending `stream.` to each hostname. Defaults derive from `QUONFIG_DOMAIN` (default `quonfig.com`). |
141
+ | `telemetry_url` | `String` | `https://telemetry.${QUONFIG_DOMAIN}` | Base URL for the telemetry service. Default derives from `QUONFIG_DOMAIN`. |
142
142
  | `enable_sse` | `Boolean` | `true` | Receive real-time updates over Server-Sent Events. |
143
143
  | `enable_polling` | `Boolean` | `false` | Poll the API on an interval as a fallback. |
144
144
  | `poll_interval` | `Integer` (seconds) | `60` | Polling interval when `enable_polling` is `true`. |
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.0.9
1
+ 0.0.10
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quonfig
4
+ # Public details record returned by Quonfig::Client#get_*_details. Surfaces
5
+ # the resolution reason and (on error) an error_code/error_message alongside
6
+ # the resolved value, so downstream layers — most importantly the
7
+ # OpenFeature provider — can map the result without losing fidelity.
8
+ #
9
+ # +reason+ is one of the strings:
10
+ # "STATIC" — config has no targeting rules; matched value is the static default
11
+ # "TARGETING_MATCH" — a targeting rule matched (any non-ALWAYS_TRUE criterion)
12
+ # "SPLIT" — matched value came from a weighted variant
13
+ # "DEFAULT" — no rule matched (eval fell through)
14
+ # "ERROR" — evaluation failed
15
+ #
16
+ # +error_code+ (only when reason == "ERROR") is one of:
17
+ # "FLAG_NOT_FOUND" — the key was unknown to the store
18
+ # "TYPE_MISMATCH" — the resolved value didn't satisfy the requested type
19
+ # "GENERAL" — any other failure
20
+ class EvaluationDetails
21
+ REASON_STATIC = 'STATIC'
22
+ REASON_TARGETING_MATCH = 'TARGETING_MATCH'
23
+ REASON_SPLIT = 'SPLIT'
24
+ REASON_DEFAULT = 'DEFAULT'
25
+ REASON_ERROR = 'ERROR'
26
+
27
+ ERROR_FLAG_NOT_FOUND = 'FLAG_NOT_FOUND'
28
+ ERROR_TYPE_MISMATCH = 'TYPE_MISMATCH'
29
+ ERROR_GENERAL = 'GENERAL'
30
+
31
+ attr_reader :value, :reason, :error_code, :error_message
32
+
33
+ def initialize(value:, reason:, error_code: nil, error_message: nil)
34
+ @value = value
35
+ @reason = reason
36
+ @error_code = error_code
37
+ @error_message = error_message
38
+ end
39
+
40
+ def ==(other)
41
+ other.is_a?(EvaluationDetails) &&
42
+ other.value == @value &&
43
+ other.reason == @reason &&
44
+ other.error_code == @error_code &&
45
+ other.error_message == @error_message
46
+ end
47
+ alias eql? ==
48
+
49
+ def hash
50
+ [@value, @reason, @error_code, @error_message].hash
51
+ end
52
+
53
+ def inspect
54
+ parts = ["value=#{@value.inspect}", "reason=#{@reason.inspect}"]
55
+ parts << "error_code=#{@error_code.inspect}" if @error_code
56
+ parts << "error_message=#{@error_message.inspect}" if @error_message
57
+ "#<Quonfig::EvaluationDetails #{parts.join(' ')}>"
58
+ end
59
+ end
60
+ end
@@ -39,10 +39,38 @@ module Quonfig
39
39
  DEFAULT_MAX_EXAMPLE_CONTEXTS = 100_000
40
40
  DEFAULT_MAX_EVAL_SUMMARIES = 100_000
41
41
 
42
+ # Hardcoded fallback domain. Overridden by ENV['QUONFIG_DOMAIN'].
43
+ DEFAULT_DOMAIN = 'quonfig.com'
44
+
45
+ # Hardcoded fallback API URLs (used only when no QUONFIG_DOMAIN is set
46
+ # and no explicit api_urls are provided). Mirrors derive_api_urls(DEFAULT_DOMAIN).
42
47
  DEFAULT_API_URLS = [
43
48
  'https://primary.quonfig.com',
49
+ 'https://secondary.quonfig.com',
44
50
  ].freeze
45
51
 
52
+ # Resolve the active domain. Reads QUONFIG_DOMAIN; falls back to
53
+ # DEFAULT_DOMAIN. Mirrors `cli/src/util/domain-urls.ts#getDomain`.
54
+ def self.domain
55
+ env = ENV['QUONFIG_DOMAIN']
56
+ env && !env.empty? ? env : DEFAULT_DOMAIN
57
+ end
58
+
59
+ # Derive default api_urls for a given domain. e.g. for domain
60
+ # `quonfig-staging.com` returns
61
+ # `["https://primary.quonfig-staging.com", "https://secondary.quonfig-staging.com"]`.
62
+ def self.derive_api_urls(domain)
63
+ [
64
+ "https://primary.#{domain}",
65
+ "https://secondary.#{domain}",
66
+ ]
67
+ end
68
+
69
+ # Derive the telemetry URL for a given domain.
70
+ def self.derive_telemetry_url(domain)
71
+ "https://telemetry.#{domain}"
72
+ end
73
+
46
74
  # Derive the SSE stream URL for a given API URL by prepending `stream.` to
47
75
  # the hostname. Preserves scheme, port, and path.
48
76
  #
@@ -58,6 +86,7 @@ module Quonfig
58
86
 
59
87
  private def init(
60
88
  api_urls: nil,
89
+ telemetry_url: nil,
61
90
  sdk_key: ENV['QUONFIG_BACKEND_SDK_KEY'],
62
91
  environment: ENV['QUONFIG_ENVIRONMENT'],
63
92
  datadir: ENV['QUONFIG_DIR'],
@@ -104,16 +133,19 @@ module Quonfig
104
133
  @collect_example_contexts = false
105
134
  @collect_max_example_contexts = 0
106
135
 
107
- if ENV['QUONFIG_API_URLS'] && ENV['QUONFIG_API_URLS'].length > 0
108
- api_urls = ENV['QUONFIG_API_URLS']
109
- end
136
+ # URL resolution order (highest wins):
137
+ # 1. Explicit kwargs (api_urls:, telemetry_url:)
138
+ # 2. ENV['QUONFIG_DOMAIN'] -> derives all three
139
+ # 3. Hardcoded DEFAULT_DOMAIN ('quonfig.com')
140
+ domain = Quonfig::Options.domain
110
141
 
111
- @api_urls = Array(api_urls || DEFAULT_API_URLS).map { |url| remove_trailing_slash(url) }
142
+ @api_urls = Array(api_urls || Quonfig::Options.derive_api_urls(domain))
143
+ .map { |url| remove_trailing_slash(url) }
112
144
 
113
145
  @sse_api_urls = @api_urls.map { |url| Quonfig::Options.derive_stream_url(url) }
114
146
  @config_api_urls = @api_urls
115
147
 
116
- @telemetry_destination = ENV['QUONFIG_TELEMETRY_URL'] || derive_telemetry_destination(@api_urls)
148
+ @telemetry_destination = telemetry_url || Quonfig::Options.derive_telemetry_url(domain)
117
149
 
118
150
  case context_upload_mode
119
151
  when :none
@@ -188,16 +220,5 @@ module Quonfig
188
220
  def remove_trailing_slash(url)
189
221
  url.end_with?('/') ? url[0..-2] : url
190
222
  end
191
-
192
- # Derive a telemetry URL from the configured api_urls by swapping the
193
- # primary/secondary host prefix for `telemetry` on a *.quonfig.com host.
194
- # Falls back to https://telemetry.quonfig.com if no URL matches.
195
- def derive_telemetry_destination(api_urls)
196
- api_urls.each do |api_url|
197
- match = api_url.match(%r{\Ahttps?://(?:primary|secondary)\.([^/]*quonfig\.com)}i)
198
- return "https://telemetry.#{match[1]}" if match
199
- end
200
- 'https://telemetry.quonfig.com'
201
- end
202
223
  end
203
224
  end
data/quonfig.gemspec CHANGED
@@ -2,16 +2,16 @@
2
2
  # DO NOT EDIT THIS FILE DIRECTLY
3
3
  # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
4
4
  # -*- encoding: utf-8 -*-
5
- # stub: quonfig 0.0.9 ruby lib
5
+ # stub: quonfig 0.0.10 ruby lib
6
6
 
7
7
  Gem::Specification.new do |s|
8
8
  s.name = "quonfig".freeze
9
- s.version = "0.0.9".freeze
9
+ s.version = "0.0.10".freeze
10
10
 
11
11
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
12
12
  s.require_paths = ["lib".freeze]
13
13
  s.authors = ["Jeff Dwyer".freeze]
14
- s.date = "2026-04-27"
14
+ s.date = "2026-05-01"
15
15
  s.description = "Quonfig \u2014 feature flags and live config, stored as files in git.".freeze
16
16
  s.email = "jeff@quonfig.com".freeze
17
17
  s.extra_rdoc_files = [
@@ -69,6 +69,7 @@ Gem::Specification.new do |s|
69
69
  "lib/quonfig/errors/type_mismatch_error.rb",
70
70
  "lib/quonfig/errors/uninitialized_error.rb",
71
71
  "lib/quonfig/evaluation.rb",
72
+ "lib/quonfig/evaluation_details.rb",
72
73
  "lib/quonfig/evaluator.rb",
73
74
  "lib/quonfig/exponential_backoff.rb",
74
75
  "lib/quonfig/fixed_size_hash.rb",
@@ -121,6 +122,7 @@ Gem::Specification.new do |s|
121
122
  "test/test_context_shape.rb",
122
123
  "test/test_context_shape_aggregator.rb",
123
124
  "test/test_datadir.rb",
125
+ "test/test_details_getters.rb",
124
126
  "test/test_dev_context.rb",
125
127
  "test/test_duration.rb",
126
128
  "test/test_encryption.rb",
@@ -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
@@ -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
data/test/test_options.rb CHANGED
@@ -5,23 +5,13 @@ require 'test_helper'
5
5
  class TestOptions < Minitest::Test
6
6
  API_KEY = 'abcdefg'
7
7
 
8
- def test_api_urls_override_env_var
9
- assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
10
-
11
- # blank doesn't take effect
12
- with_env('QUONFIG_API_URLS', '') do
13
- assert_equal Quonfig::Options::DEFAULT_API_URLS, Quonfig::Options.new.api_urls
14
- end
15
-
16
- # non-blank does take effect
17
- with_env('QUONFIG_API_URLS', 'https://override.example.com') do
18
- assert_equal ["https://override.example.com"], Quonfig::Options.new.api_urls
19
- end
20
- end
21
-
22
8
  def test_default_api_urls_point_to_quonfig
9
+ # DEFAULT_API_URLS is the hardcoded fallback when neither QUONFIG_DOMAIN
10
+ # nor an explicit api_urls: kwarg is set. Domain-derivation happens at
11
+ # construction time, not at constant load time — see derive_api_urls.
23
12
  assert_equal [
24
13
  'https://primary.quonfig.com',
14
+ 'https://secondary.quonfig.com',
25
15
  ], Quonfig::Options::DEFAULT_API_URLS
26
16
  end
27
17
 
@@ -150,18 +140,59 @@ class TestOptions < Minitest::Test
150
140
  assert_equal 0, options.collect_max_shapes
151
141
  end
152
142
 
153
- def test_telemetry_destination_reads_env_first
154
- with_env('QUONFIG_TELEMETRY_URL', 'https://custom-telemetry.example.com') do
155
- assert_equal 'https://custom-telemetry.example.com', Quonfig::Options.new.telemetry_destination
143
+ # ---- QUONFIG_DOMAIN tests (qfg-w6gg) ----
144
+ # A single env var `QUONFIG_DOMAIN` governs api, sse, and telemetry URL
145
+ # derivation. Resolution order (highest wins): explicit kwargs >
146
+ # QUONFIG_DOMAIN > hardcoded default 'quonfig.com'.
147
+
148
+ def test_default_domain_is_quonfig_com
149
+ with_env('QUONFIG_DOMAIN', nil) do
150
+ options = Quonfig::Options.new
151
+ assert_equal [
152
+ 'https://primary.quonfig.com',
153
+ 'https://secondary.quonfig.com',
154
+ ], options.api_urls
155
+ assert_equal 'https://telemetry.quonfig.com', options.telemetry_destination
156
+ end
157
+ end
158
+
159
+ def test_quonfig_domain_env_var_derives_all_urls
160
+ with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
161
+ options = Quonfig::Options.new
162
+ assert_equal [
163
+ 'https://primary.quonfig-staging.com',
164
+ 'https://secondary.quonfig-staging.com',
165
+ ], options.api_urls
166
+ assert_equal [
167
+ 'https://stream.primary.quonfig-staging.com',
168
+ 'https://stream.secondary.quonfig-staging.com',
169
+ ], options.sse_api_urls
170
+ assert_equal 'https://telemetry.quonfig-staging.com', options.telemetry_destination
171
+ end
172
+ end
173
+
174
+ def test_explicit_api_urls_override_quonfig_domain
175
+ with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
176
+ options = Quonfig::Options.new(api_urls: ['http://localhost:8080'])
177
+ assert_equal ['http://localhost:8080'], options.api_urls
156
178
  end
157
179
  end
158
180
 
159
- def test_telemetry_destination_derives_from_default_sources
160
- assert_equal 'https://telemetry.quonfig.com', Quonfig::Options.new.telemetry_destination
181
+ def test_explicit_telemetry_url_overrides_quonfig_domain
182
+ with_env('QUONFIG_DOMAIN', 'quonfig-staging.com') do
183
+ options = Quonfig::Options.new(telemetry_url: 'http://localhost:6555')
184
+ assert_equal 'http://localhost:6555', options.telemetry_destination
185
+ end
161
186
  end
162
187
 
163
- def test_telemetry_destination_derives_from_custom_quonfig_api_urls
164
- options = Quonfig::Options.new(api_urls: ['https://primary.eu.quonfig.com'])
165
- assert_equal 'https://telemetry.eu.quonfig.com', options.telemetry_destination
188
+ def test_quonfig_telemetry_url_env_var_no_longer_read
189
+ # QUONFIG_TELEMETRY_URL has been removed. Setting it must not affect
190
+ # anything; the default (quonfig.com) wins.
191
+ with_env('QUONFIG_DOMAIN', nil) do
192
+ with_env('QUONFIG_TELEMETRY_URL', 'https://should-be-ignored.example.com') do
193
+ assert_equal 'https://telemetry.quonfig.com',
194
+ Quonfig::Options.new.telemetry_destination
195
+ end
196
+ end
166
197
  end
167
198
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quonfig
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.9
4
+ version: 0.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Dwyer
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-27 00:00:00.000000000 Z
11
+ date: 2026-05-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -228,6 +228,7 @@ files:
228
228
  - lib/quonfig/errors/type_mismatch_error.rb
229
229
  - lib/quonfig/errors/uninitialized_error.rb
230
230
  - lib/quonfig/evaluation.rb
231
+ - lib/quonfig/evaluation_details.rb
231
232
  - lib/quonfig/evaluator.rb
232
233
  - lib/quonfig/exponential_backoff.rb
233
234
  - lib/quonfig/fixed_size_hash.rb
@@ -280,6 +281,7 @@ files:
280
281
  - test/test_context_shape.rb
281
282
  - test/test_context_shape_aggregator.rb
282
283
  - test/test_datadir.rb
284
+ - test/test_details_getters.rb
283
285
  - test/test_dev_context.rb
284
286
  - test/test_duration.rb
285
287
  - test/test_encryption.rb