quonfig 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/VERSION +1 -1
- data/lib/quonfig/client.rb +109 -2
- data/lib/quonfig/context.rb +10 -1
- data/lib/quonfig/datadir.rb +2 -4
- data/lib/quonfig/errors/decryption_error.rb +20 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
- data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
- data/lib/quonfig/errors/missing_environment_error.rb +18 -0
- data/lib/quonfig/evaluator.rb +64 -2
- data/lib/quonfig/http_connection.rb +1 -1
- data/lib/quonfig/resolver.rb +187 -2
- data/lib/quonfig/stdlib_formatter.rb +95 -0
- data/lib/quonfig/telemetry/context_shape.rb +33 -0
- data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
- data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
- data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
- data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
- data/lib/quonfig.rb +8 -0
- data/quonfig.gemspec +20 -4
- data/test/integration/test_context_precedence.rb +35 -117
- data/test/integration/test_datadir_environment.rb +15 -37
- data/test/integration/test_enabled.rb +157 -463
- data/test/integration/test_enabled_with_contexts.rb +19 -49
- data/test/integration/test_get.rb +43 -131
- data/test/integration/test_get_feature_flag.rb +7 -13
- data/test/integration/test_get_or_raise.rb +19 -45
- data/test/integration/test_get_weighted_values.rb +9 -4
- data/test/integration/test_helpers.rb +499 -4
- data/test/integration/test_post.rb +15 -5
- data/test/integration/test_telemetry.rb +63 -21
- data/test/test_client_telemetry.rb +132 -0
- data/test/test_context.rb +4 -1
- data/test/test_context_shape.rb +37 -0
- data/test/test_context_shape_aggregator.rb +126 -0
- data/test/test_datadir.rb +6 -2
- data/test/test_evaluation_summaries_aggregator.rb +180 -0
- data/test/test_example_contexts_aggregator.rb +119 -0
- data/test/test_http_connection.rb +1 -1
- data/test/test_resolver.rb +149 -2
- data/test/test_should_log.rb +186 -0
- data/test/test_stdlib_formatter.rb +195 -0
- data/test/test_telemetry_reporter.rb +209 -0
- metadata +19 -3
- data/scripts/generate_integration_tests.rb +0 -362
|
@@ -56,6 +56,7 @@ module IntegrationTestHelpers
|
|
|
56
56
|
store.set(key, cfg)
|
|
57
57
|
end
|
|
58
58
|
end
|
|
59
|
+
self.last_store = store
|
|
59
60
|
store
|
|
60
61
|
end
|
|
61
62
|
|
|
@@ -66,13 +67,25 @@ module IntegrationTestHelpers
|
|
|
66
67
|
|
|
67
68
|
# Resolve +key+ against +context+ and assert the unwrapped value (and,
|
|
68
69
|
# when present, its reported value_type) match. Generated tests call
|
|
69
|
-
# this
|
|
70
|
+
# this for the "no default, no enabled" path. With the generator now
|
|
71
|
+
# threading input.default through assert_get_with_default and routing
|
|
72
|
+
# function: enabled cases through assert_enabled, this helper can stay
|
|
73
|
+
# strict: missing keys still raise, non-bool actual stays non-bool.
|
|
74
|
+
# Nil-expected cases (e.g. "get returns nil if value not found" with
|
|
75
|
+
# on_no_default: 2) catch the resolver's MissingDefaultError and return nil.
|
|
70
76
|
def self.assert_resolved(resolver, key, context, expected_value, expected_type = nil)
|
|
71
77
|
ctx = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context || {})
|
|
72
|
-
result =
|
|
73
|
-
|
|
78
|
+
result =
|
|
79
|
+
begin
|
|
80
|
+
resolver.get(key, ctx)
|
|
81
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
return expected_value if result.nil? && expected_value.nil?
|
|
74
85
|
|
|
75
|
-
actual = if result.
|
|
86
|
+
actual = if result.nil?
|
|
87
|
+
nil
|
|
88
|
+
elsif result.respond_to?(:unwrapped_value)
|
|
76
89
|
result.unwrapped_value
|
|
77
90
|
elsif result.respond_to?(:value)
|
|
78
91
|
v = result.value
|
|
@@ -95,6 +108,152 @@ module IntegrationTestHelpers
|
|
|
95
108
|
actual
|
|
96
109
|
end
|
|
97
110
|
|
|
111
|
+
# function: enabled semantics — Quonfig::Client#enabled? returns the
|
|
112
|
+
# bool value if the resolved value is a boolean, false otherwise.
|
|
113
|
+
# The generator routes function: enabled cases through this helper so
|
|
114
|
+
# the bool-coercion lives here, not inferred from the expected literal.
|
|
115
|
+
def self.assert_enabled(resolver, key, context, expected_bool)
|
|
116
|
+
ctx = context.is_a?(Quonfig::Context) ? context : Quonfig::Context.new(context || {})
|
|
117
|
+
actual =
|
|
118
|
+
begin
|
|
119
|
+
result = resolver.get(key, ctx)
|
|
120
|
+
if result.nil?
|
|
121
|
+
false
|
|
122
|
+
else
|
|
123
|
+
v = result.respond_to?(:unwrapped_value) ? result.unwrapped_value : result
|
|
124
|
+
(v == true || v == 'true') ? true : false
|
|
125
|
+
end
|
|
126
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
unless actual == expected_bool
|
|
130
|
+
raise Minitest::Assertion,
|
|
131
|
+
"enabled?(#{key}): expected #{expected_bool.inspect}, got #{actual.inspect}"
|
|
132
|
+
end
|
|
133
|
+
actual
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# input.default — thread the YAML default through the SDK's public
|
|
137
|
+
# get(key, default) API. Build a Client over the same store the
|
|
138
|
+
# resolver uses; that way we observe what the SDK actually returns
|
|
139
|
+
# (default kicks in for missing keys, found-key wins over default).
|
|
140
|
+
def self.assert_get_with_default(store, key, context, default_value, expected_value)
|
|
141
|
+
# Build with environment: ENV_ID so config rules evaluate against the
|
|
142
|
+
# 'Production' environment (matching what build_resolver does). Without
|
|
143
|
+
# this the Client falls back to default rules.
|
|
144
|
+
client = Quonfig::Client.new(store: store, environment: ENV_ID)
|
|
145
|
+
ctx_arg =
|
|
146
|
+
if context.nil? || (context.respond_to?(:empty?) && context.empty?)
|
|
147
|
+
Quonfig::NO_DEFAULT_PROVIDED
|
|
148
|
+
elsif context.is_a?(Quonfig::Context)
|
|
149
|
+
context
|
|
150
|
+
else
|
|
151
|
+
Quonfig::Context.new(context)
|
|
152
|
+
end
|
|
153
|
+
actual = client.get(key, default_value, ctx_arg)
|
|
154
|
+
unless actual == expected_value
|
|
155
|
+
raise Minitest::Assertion,
|
|
156
|
+
"#{key}: expected #{expected_value.inspect} (default=#{default_value.inspect}), got #{actual.inspect}"
|
|
157
|
+
end
|
|
158
|
+
actual
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Build a real Quonfig::Client whose initial fetch is intentionally slow
|
|
162
|
+
# (an unreachable api_url + tiny init timeout) and assert that
|
|
163
|
+
# Client#get raises Quonfig::Errors::InitializationTimeoutError.
|
|
164
|
+
def self.assert_initialization_timeout_error(key, timeout_sec, api_url, on_init_failure)
|
|
165
|
+
on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
|
|
166
|
+
api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
|
|
167
|
+
client =
|
|
168
|
+
begin
|
|
169
|
+
Quonfig::Client.new(
|
|
170
|
+
sdk_key: 'test-unused',
|
|
171
|
+
api_urls: api_urls,
|
|
172
|
+
initialization_timeout_sec: timeout_sec,
|
|
173
|
+
on_init_failure: on_init,
|
|
174
|
+
enable_sse: false,
|
|
175
|
+
enable_polling: false
|
|
176
|
+
)
|
|
177
|
+
rescue Quonfig::Errors::InitializationTimeoutError
|
|
178
|
+
return # construction itself raised — that's the expected outcome
|
|
179
|
+
end
|
|
180
|
+
raise Minitest::Assertion,
|
|
181
|
+
'expected Quonfig::Errors::InitializationTimeoutError to raise on get' \
|
|
182
|
+
unless on_init == :raise
|
|
183
|
+
|
|
184
|
+
begin
|
|
185
|
+
client.get(key)
|
|
186
|
+
raise Minitest::Assertion, "expected get(#{key}) to raise InitializationTimeoutError but it returned"
|
|
187
|
+
rescue Quonfig::Errors::InitializationTimeoutError
|
|
188
|
+
# success
|
|
189
|
+
ensure
|
|
190
|
+
client.respond_to?(:close) && client.close
|
|
191
|
+
$logs = nil if defined?($logs)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Generic raise path through a real-client construction (e.g. on_init_failure
|
|
196
|
+
# :return + missing_default on get_or_raise — init returns zero value,
|
|
197
|
+
# then get_or_raise still raises MissingDefault). The function arg picks
|
|
198
|
+
# the call shape: 'get_or_raise' uses get_or_raise(key); anything else
|
|
199
|
+
# falls back to client.get(key).
|
|
200
|
+
#
|
|
201
|
+
# The Client logs a warning when init returns the zero value (the typical
|
|
202
|
+
# on_init_failure: :return path). Drain $logs (if it exists from
|
|
203
|
+
# CommonHelpers) so the test's teardown doesn't trip on it — that's the
|
|
204
|
+
# whole point of the case.
|
|
205
|
+
def self.assert_client_construction_raises(key, timeout_sec, api_url, on_init_failure, fn, err_class)
|
|
206
|
+
on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
|
|
207
|
+
api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
|
|
208
|
+
client = Quonfig::Client.new(
|
|
209
|
+
sdk_key: 'test-unused',
|
|
210
|
+
api_urls: api_urls,
|
|
211
|
+
initialization_timeout_sec: timeout_sec,
|
|
212
|
+
on_init_failure: on_init,
|
|
213
|
+
enable_sse: false,
|
|
214
|
+
enable_polling: false
|
|
215
|
+
)
|
|
216
|
+
begin
|
|
217
|
+
if fn == 'get_or_raise' && client.respond_to?(:get_or_raise)
|
|
218
|
+
client.get_or_raise(key)
|
|
219
|
+
else
|
|
220
|
+
# No public get_or_raise: call .get with no default and the SDK's
|
|
221
|
+
# internal NO_DEFAULT_PROVIDED forces the missing-default raise.
|
|
222
|
+
client.get(key)
|
|
223
|
+
end
|
|
224
|
+
raise Minitest::Assertion, "expected #{err_class} to raise but call returned"
|
|
225
|
+
rescue err_class
|
|
226
|
+
# success
|
|
227
|
+
ensure
|
|
228
|
+
client.respond_to?(:close) && client.close
|
|
229
|
+
# Acknowledge the init-warning log so common_helpers' teardown won't
|
|
230
|
+
# blow up. The warning IS the thing we asked for via :return policy.
|
|
231
|
+
$logs = nil if defined?($logs)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Happy path through a real-client construction (rare; mostly here for
|
|
236
|
+
# symmetry — the YAML init-timeout cases are all raise-path).
|
|
237
|
+
def self.assert_client_construction_value(key, timeout_sec, api_url, on_init_failure, _fn, expected_value)
|
|
238
|
+
on_init = on_init_failure.to_s.sub(/\A:/, '').to_sym
|
|
239
|
+
api_urls = api_url && !api_url.empty? ? [api_url] : ['https://127.0.0.1:1']
|
|
240
|
+
client = Quonfig::Client.new(
|
|
241
|
+
sdk_key: 'test-unused',
|
|
242
|
+
api_urls: api_urls,
|
|
243
|
+
initialization_timeout_sec: timeout_sec,
|
|
244
|
+
on_init_failure: on_init,
|
|
245
|
+
enable_sse: false,
|
|
246
|
+
enable_polling: false
|
|
247
|
+
)
|
|
248
|
+
actual = client.get(key)
|
|
249
|
+
unless actual == expected_value
|
|
250
|
+
raise Minitest::Assertion,
|
|
251
|
+
"#{key}: expected #{expected_value.inspect}, got #{actual.inspect}"
|
|
252
|
+
end
|
|
253
|
+
client.respond_to?(:close) && client.close
|
|
254
|
+
actual
|
|
255
|
+
end
|
|
256
|
+
|
|
98
257
|
# Temporarily set env vars for the duration of the block and restore the
|
|
99
258
|
# originals (including absence) on exit — even if the block raises.
|
|
100
259
|
def self.with_env(vars_hash)
|
|
@@ -114,6 +273,342 @@ module IntegrationTestHelpers
|
|
|
114
273
|
end
|
|
115
274
|
end
|
|
116
275
|
|
|
276
|
+
# ----------------------------------------------------------------------
|
|
277
|
+
# Aggregator helpers for the post.yaml + telemetry.yaml generated suites.
|
|
278
|
+
# ----------------------------------------------------------------------
|
|
279
|
+
#
|
|
280
|
+
# The shared YAML in integration-test-data/tests/eval/{post,telemetry}.yaml
|
|
281
|
+
# describes telemetry payloads in language-neutral terms — `aggregator:
|
|
282
|
+
# context_shape | evaluation_summary | example_contexts` plus a `data`
|
|
283
|
+
# block of inputs and an `expected_data` block of the would-be POST body.
|
|
284
|
+
# The Ruby generator emits one method per case calling these three
|
|
285
|
+
# helpers. They wire the YAML inputs through the real aggregator classes
|
|
286
|
+
# (Quonfig::Telemetry::*) and translate the aggregator's drain_event
|
|
287
|
+
# output into the YAML's snake_case schema for assertion.
|
|
288
|
+
#
|
|
289
|
+
# Recent build_store call stashes the Quonfig::ConfigStore on the module
|
|
290
|
+
# so eval-summary cases can resolve real values for each key.
|
|
291
|
+
class << self
|
|
292
|
+
attr_accessor :last_store
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Construct an aggregator. +kind+ is one of :context_shape,
|
|
296
|
+
# :evaluation_summary, :example_contexts (string or symbol). +overrides+
|
|
297
|
+
# mirrors the YAML `client_overrides` block; the only options that
|
|
298
|
+
# affect aggregator output today are `collect_evaluation_summaries`
|
|
299
|
+
# (false → eval-summary aggregator created with max_keys=0 so it noops)
|
|
300
|
+
# and `context_upload_mode` ("shape_only" / "none" → example-contexts
|
|
301
|
+
# aggregator created with max=0; ":none" / ":shape_only" come through
|
|
302
|
+
# as Ruby-symbol strings via js-yaml, which is why we strip the leading
|
|
303
|
+
# colon defensively).
|
|
304
|
+
def self.build_aggregator(kind, overrides = {})
|
|
305
|
+
overrides = (overrides || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
306
|
+
case normalize_kind(kind)
|
|
307
|
+
when :context_shape
|
|
308
|
+
max = aggregator_max_for(overrides, :context_shape)
|
|
309
|
+
Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: max)
|
|
310
|
+
when :evaluation_summary
|
|
311
|
+
collect = overrides.fetch('collect_evaluation_summaries', true)
|
|
312
|
+
max = collect ? 100_000 : 0
|
|
313
|
+
Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max)
|
|
314
|
+
when :example_contexts
|
|
315
|
+
max = aggregator_max_for(overrides, :example_contexts)
|
|
316
|
+
Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: max)
|
|
317
|
+
else
|
|
318
|
+
raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Feed +data+ through +aggregator+ for the given +kind+. Each kind has
|
|
323
|
+
# its own input shape (see post.yaml / telemetry.yaml):
|
|
324
|
+
# - :context_shape → +data+ is a Hash of named contexts, OR an Array
|
|
325
|
+
# of such hashes (multi-record case).
|
|
326
|
+
# - :evaluation_summary
|
|
327
|
+
# → +data+ is { 'keys' => [...], 'keys_without_context'
|
|
328
|
+
# => [...] }. Each key is resolved against
|
|
329
|
+
# +contexts+ (or empty contexts for the second
|
|
330
|
+
# list), then the EvalResult is recorded.
|
|
331
|
+
# - :example_contexts → same as :context_shape but recorded into the
|
|
332
|
+
# example aggregator.
|
|
333
|
+
def self.feed_aggregator(aggregator, kind, data, contexts: {})
|
|
334
|
+
case normalize_kind(kind)
|
|
335
|
+
when :context_shape
|
|
336
|
+
each_context_record(data) { |rec| aggregator.push(rec) }
|
|
337
|
+
when :example_contexts
|
|
338
|
+
each_context_record(data) { |rec| aggregator.record(Quonfig::Context.new(rec)) }
|
|
339
|
+
when :evaluation_summary
|
|
340
|
+
record_eval_keys(aggregator, data, contexts)
|
|
341
|
+
else
|
|
342
|
+
raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Drain the aggregator and assert its would-be POST body matches
|
|
347
|
+
# +expected_data+. +endpoint+ is captured from YAML for diagnostics
|
|
348
|
+
# (the Ruby helpers don't actually POST anything — the aggregator's
|
|
349
|
+
# drain_event payload is what the reporter would ship). +expected_data+
|
|
350
|
+
# is YAML-shaped (snake_case `field_types` etc.); we project the
|
|
351
|
+
# aggregator's drain output into that shape so the comparison is
|
|
352
|
+
# apples-to-apples.
|
|
353
|
+
def self.assert_aggregator_post(aggregator, kind, expected_data, endpoint:)
|
|
354
|
+
actual = build_actual_post(aggregator, kind)
|
|
355
|
+
|
|
356
|
+
if expected_data.nil?
|
|
357
|
+
unless actual.nil?
|
|
358
|
+
raise Minitest::Assertion,
|
|
359
|
+
"[#{endpoint}] expected no telemetry POST but aggregator produced #{actual.inspect}"
|
|
360
|
+
end
|
|
361
|
+
return
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
expected_normalized, actual_normalized = align_for_comparison(expected_data, actual, kind)
|
|
365
|
+
actual_normalized = scrub_optional_fields(actual_normalized, expected_normalized, kind)
|
|
366
|
+
|
|
367
|
+
unless actual_normalized == expected_normalized
|
|
368
|
+
raise Minitest::Assertion,
|
|
369
|
+
"[#{endpoint}] aggregator POST mismatch\n expected: #{expected_normalized.inspect}\n actual: #{actual_normalized.inspect}"
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Normalize ordering on both sides for comparison. Telemetry payloads
|
|
374
|
+
# are conceptually unordered sets — different SDKs (and different runs
|
|
375
|
+
# within a single SDK if you swap a Hash for a different impl) emit
|
|
376
|
+
# entries in different orders. Sort by a stable key so the comparison
|
|
377
|
+
# is set-equality. Keeps assertion errors readable: both sides print in
|
|
378
|
+
# the same canonical order.
|
|
379
|
+
def self.align_for_comparison(expected, actual, kind)
|
|
380
|
+
case normalize_kind(kind)
|
|
381
|
+
when :evaluation_summary
|
|
382
|
+
sort_key = ->(row) { [row['key'].to_s, (row.dig('summary', 'conditional_value_index') || 0)] }
|
|
383
|
+
[expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
|
|
384
|
+
actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
|
|
385
|
+
when :context_shape
|
|
386
|
+
sort_key = ->(row) { row['name'].to_s }
|
|
387
|
+
[expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
|
|
388
|
+
actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
|
|
389
|
+
else
|
|
390
|
+
[expected, actual]
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
private_class_method :align_for_comparison
|
|
394
|
+
|
|
395
|
+
# Drop fields from +actual+ that the YAML's +expected+ doesn't assert.
|
|
396
|
+
# Today only `selected_value` in eval-summary rows is opt-in (some YAML
|
|
397
|
+
# cases verify the proto-style wrapper, most don't). Index pairwise so
|
|
398
|
+
# the per-row decision lines up.
|
|
399
|
+
def self.scrub_optional_fields(actual, expected, kind)
|
|
400
|
+
return actual unless normalize_kind(kind) == :evaluation_summary
|
|
401
|
+
return actual unless actual.is_a?(Array) && expected.is_a?(Array)
|
|
402
|
+
|
|
403
|
+
actual.each_with_index.map do |row, idx|
|
|
404
|
+
exp_row = expected[idx]
|
|
405
|
+
next row unless row.is_a?(Hash) && exp_row.is_a?(Hash)
|
|
406
|
+
next row if exp_row.key?('selected_value')
|
|
407
|
+
|
|
408
|
+
row.reject { |k, _| k == 'selected_value' }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
private_class_method :scrub_optional_fields
|
|
412
|
+
|
|
413
|
+
# --- aggregator-helper internals ---
|
|
414
|
+
|
|
415
|
+
def self.normalize_kind(kind)
|
|
416
|
+
str = kind.to_s
|
|
417
|
+
str = str.sub(/\A:/, '') # ":shape_only" → "shape_only" if a stray symbol-string sneaks in
|
|
418
|
+
case str
|
|
419
|
+
when 'context_shape' then :context_shape
|
|
420
|
+
when 'evaluation_summary' then :evaluation_summary
|
|
421
|
+
when 'example_contexts' then :example_contexts
|
|
422
|
+
else raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
private_class_method :normalize_kind
|
|
426
|
+
|
|
427
|
+
# Strip a leading `:` so a Ruby-symbol-style YAML scalar (":shape_only"
|
|
428
|
+
# → ":shape_only" string when js-yaml serializes it) compares cleanly.
|
|
429
|
+
def self.strip_symbol(v)
|
|
430
|
+
v.is_a?(String) ? v.sub(/\A:/, '') : v.to_s
|
|
431
|
+
end
|
|
432
|
+
private_class_method :strip_symbol
|
|
433
|
+
|
|
434
|
+
def self.aggregator_max_for(overrides, agg_kind)
|
|
435
|
+
mode = strip_symbol(overrides['context_upload_mode']) if overrides.key?('context_upload_mode')
|
|
436
|
+
return 0 if mode == 'none'
|
|
437
|
+
|
|
438
|
+
case agg_kind
|
|
439
|
+
when :context_shape then 100_000
|
|
440
|
+
when :example_contexts then mode == 'shape_only' ? 0 : 100_000
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
private_class_method :aggregator_max_for
|
|
444
|
+
|
|
445
|
+
def self.each_context_record(data)
|
|
446
|
+
return if data.nil?
|
|
447
|
+
|
|
448
|
+
if data.is_a?(Array)
|
|
449
|
+
data.each { |row| yield row if row.is_a?(Hash) && !row.empty? }
|
|
450
|
+
elsif data.is_a?(Hash)
|
|
451
|
+
yield data unless data.empty?
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
private_class_method :each_context_record
|
|
455
|
+
|
|
456
|
+
def self.record_eval_keys(aggregator, data, contexts)
|
|
457
|
+
return unless data.is_a?(Hash)
|
|
458
|
+
|
|
459
|
+
keys = data['keys'] || data[:keys] || []
|
|
460
|
+
keys_no_ctx = data['keys_without_context'] || data[:keys_without_context] || []
|
|
461
|
+
store = last_store
|
|
462
|
+
raise '[integration tests] no store cached — call build_store before feed_aggregator' if store.nil?
|
|
463
|
+
|
|
464
|
+
resolver = build_resolver(store)
|
|
465
|
+
ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
|
|
466
|
+
empty_ctx = Quonfig::Context.new({})
|
|
467
|
+
|
|
468
|
+
Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
|
|
469
|
+
Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
|
|
470
|
+
end
|
|
471
|
+
private_class_method :record_eval_keys
|
|
472
|
+
|
|
473
|
+
def self.record_one_eval(aggregator, resolver, store, key, ctx)
|
|
474
|
+
cfg = store.get(key)
|
|
475
|
+
return if cfg.nil?
|
|
476
|
+
|
|
477
|
+
result =
|
|
478
|
+
begin
|
|
479
|
+
resolver.get(key, ctx)
|
|
480
|
+
rescue Quonfig::Errors::MissingDefaultError
|
|
481
|
+
nil
|
|
482
|
+
end
|
|
483
|
+
return if result.nil?
|
|
484
|
+
|
|
485
|
+
aggregator.record(
|
|
486
|
+
config_id: (cfg[:id] || cfg['id']).to_s,
|
|
487
|
+
config_key: key,
|
|
488
|
+
config_type: (cfg[:type] || cfg['type']).to_s,
|
|
489
|
+
conditional_value_index: result.rule_index,
|
|
490
|
+
weighted_value_index: result.weighted_value_index,
|
|
491
|
+
selected_value: result.unwrapped_value,
|
|
492
|
+
reason: result.wire_reason
|
|
493
|
+
)
|
|
494
|
+
end
|
|
495
|
+
private_class_method :record_one_eval
|
|
496
|
+
|
|
497
|
+
# Project +aggregator+'s drain_event payload onto the YAML's
|
|
498
|
+
# snake_case `expected_data` schema. Returns nil when the aggregator
|
|
499
|
+
# produced nothing (matches YAML's bare `expected_data:` lines).
|
|
500
|
+
def self.build_actual_post(aggregator, kind)
|
|
501
|
+
event = aggregator.drain_event
|
|
502
|
+
return nil if event.nil?
|
|
503
|
+
|
|
504
|
+
case normalize_kind(kind)
|
|
505
|
+
when :context_shape then context_shape_post(event)
|
|
506
|
+
when :evaluation_summary then evaluation_summary_post(event)
|
|
507
|
+
when :example_contexts then example_contexts_post(event)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
private_class_method :build_actual_post
|
|
511
|
+
|
|
512
|
+
def self.context_shape_post(event)
|
|
513
|
+
shapes = event.dig('contextShapes', 'shapes') || []
|
|
514
|
+
return nil if shapes.empty?
|
|
515
|
+
|
|
516
|
+
shapes.map do |shape|
|
|
517
|
+
{ 'name' => shape['name'], 'field_types' => shape['fieldTypes'] }
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
private_class_method :context_shape_post
|
|
521
|
+
|
|
522
|
+
def self.example_contexts_post(event)
|
|
523
|
+
examples = event.dig('exampleContexts', 'examples') || []
|
|
524
|
+
return nil if examples.empty?
|
|
525
|
+
|
|
526
|
+
# post.yaml expects a single context-set object (the first / only
|
|
527
|
+
# example), keyed by named-context-name. Multiple examples are not
|
|
528
|
+
# exercised in the YAML; if they ever are, this helper still picks
|
|
529
|
+
# the first dedup'd record, matching sdk-node's wire shape.
|
|
530
|
+
contexts = examples.first.dig('contextSet', 'contexts') || []
|
|
531
|
+
contexts.each_with_object({}) do |ctx, acc|
|
|
532
|
+
acc[ctx['type']] = ctx['values']
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
private_class_method :example_contexts_post
|
|
536
|
+
|
|
537
|
+
TYPE_LABELS = {
|
|
538
|
+
'config' => 'CONFIG',
|
|
539
|
+
'feature_flag' => 'FEATURE_FLAG',
|
|
540
|
+
'segment' => 'SEGMENT',
|
|
541
|
+
'log_level' => 'LOG_LEVEL',
|
|
542
|
+
'schema' => 'SCHEMA'
|
|
543
|
+
}.freeze
|
|
544
|
+
private_constant :TYPE_LABELS
|
|
545
|
+
|
|
546
|
+
def self.evaluation_summary_post(event)
|
|
547
|
+
summaries = event.dig('summaries', 'summaries') || []
|
|
548
|
+
rows = []
|
|
549
|
+
summaries.each do |summary|
|
|
550
|
+
type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
|
|
551
|
+
counters = summary['counters'] || []
|
|
552
|
+
counters.each do |counter|
|
|
553
|
+
selected = counter['selectedValue'] || {}
|
|
554
|
+
unwrapped, value_type = unwrap_selected(selected)
|
|
555
|
+
row = {
|
|
556
|
+
'key' => summary['key'],
|
|
557
|
+
'type' => type_label,
|
|
558
|
+
'value' => unwrapped,
|
|
559
|
+
'value_type' => value_type,
|
|
560
|
+
'count' => counter['count'],
|
|
561
|
+
'reason' => counter['reason']
|
|
562
|
+
}
|
|
563
|
+
if counter.key?('selectedValue')
|
|
564
|
+
# YAML test cases that assert `selected_value:` use the raw
|
|
565
|
+
# tagged shape (e.g. {"string" => "hello.world"}). We always
|
|
566
|
+
# emit it; the diff's expected_data will simply not include
|
|
567
|
+
# the field for cases that don't care.
|
|
568
|
+
row['selected_value'] = selected
|
|
569
|
+
end
|
|
570
|
+
summary_block = {
|
|
571
|
+
'config_row_index' => counter['configRowIndex'],
|
|
572
|
+
'conditional_value_index' => counter['conditionalValueIndex']
|
|
573
|
+
}
|
|
574
|
+
if counter.key?('weightedValueIndex')
|
|
575
|
+
summary_block['weighted_value_index'] = counter['weightedValueIndex']
|
|
576
|
+
end
|
|
577
|
+
row['summary'] = summary_block
|
|
578
|
+
rows << row
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
return nil if rows.empty?
|
|
582
|
+
|
|
583
|
+
# Strip selected_value from rows whose YAML cases don't assert it.
|
|
584
|
+
# We can't know that here, so emit it conditionally based on the
|
|
585
|
+
# caller. The simpler path is to only include selected_value when
|
|
586
|
+
# the YAML case asks for it. To keep this stateless we drop it by
|
|
587
|
+
# default; the helper re-adds it on request via opt-in (see
|
|
588
|
+
# #with_selected_values below). Most cases assert without it.
|
|
589
|
+
rows
|
|
590
|
+
end
|
|
591
|
+
private_class_method :evaluation_summary_post
|
|
592
|
+
|
|
593
|
+
# Decode the proto-style selectedValue wrapper { "<type>" => <val> }
|
|
594
|
+
# into [unwrapped_value, value_type_label]. Mirrors the keys used by
|
|
595
|
+
# EvaluationSummariesAggregator#wrap_selected_value (bool/int/double/
|
|
596
|
+
# string/stringList).
|
|
597
|
+
def self.unwrap_selected(selected)
|
|
598
|
+
return [nil, nil] unless selected.is_a?(Hash) && selected.size == 1
|
|
599
|
+
|
|
600
|
+
key, value = selected.first
|
|
601
|
+
case key
|
|
602
|
+
when 'bool' then [value, 'bool']
|
|
603
|
+
when 'int' then [value, 'int']
|
|
604
|
+
when 'double' then [value, 'double']
|
|
605
|
+
when 'string' then [value, 'string']
|
|
606
|
+
when 'stringList' then [value, 'string_list']
|
|
607
|
+
else [value, key]
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
private_class_method :unwrap_selected
|
|
611
|
+
|
|
117
612
|
# Normalize the raw JSON config on disk into the shape the rest of the
|
|
118
613
|
# suite expects: one environment row for ENV_ID pulled out of the
|
|
119
614
|
# top-level `environments` array. Matches sdk-node/setup.ts:toConfigResponse.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
#
|
|
3
3
|
# AUTO-GENERATED from integration-test-data/tests/eval/post.yaml.
|
|
4
|
-
# Regenerate with
|
|
4
|
+
# Regenerate with:
|
|
5
|
+
# cd integration-test-data/generators && npm run generate -- --target=ruby
|
|
6
|
+
# Source: integration-test-data/generators/src/targets/ruby.ts
|
|
5
7
|
# Do NOT edit by hand — changes will be overwritten.
|
|
6
8
|
|
|
7
9
|
require 'test_helper'
|
|
@@ -14,21 +16,29 @@ class TestPost < Minitest::Test
|
|
|
14
16
|
|
|
15
17
|
# reports context shape aggregation
|
|
16
18
|
def test_reports_context_shape_aggregation
|
|
17
|
-
|
|
19
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:context_shape, {"context_upload_mode" => ":shape_only"})
|
|
20
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :context_shape, {"user" => {"name" => "Michael", "age" => 38, "human" => true}, "role" => {"name" => "developer", "admin" => false, "salary" => 15.75, "permissions" => ["read", "write"]}}, contexts: {})
|
|
21
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :context_shape, [{"name" => "user", "field_types" => {"name" => 2, "age" => 1, "human" => 5}}, {"name" => "role", "field_types" => {"name" => 2, "admin" => 5, "salary" => 4, "permissions" => 10}}], endpoint: "/api/v1/context-shapes")
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
# reports evaluation summary
|
|
21
25
|
def test_reports_evaluation_summary
|
|
22
|
-
|
|
26
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:evaluation_summary, {})
|
|
27
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :evaluation_summary, {"keys" => ["my-test-key", "feature-flag.integer", "my-string-list-key", "feature-flag.integer", "feature-flag.weighted"]}, contexts: {"user" => {"tracking_id" => "92a202f2"}})
|
|
28
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :evaluation_summary, [{"key" => "my-test-key", "type" => "CONFIG", "value" => "my-test-value", "value_type" => "string", "count" => 1, "reason" => 2, "selected_value" => {"string" => "my-test-value"}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 1}}, {"key" => "my-string-list-key", "type" => "CONFIG", "value" => ["a", "b", "c"], "value_type" => "string_list", "count" => 1, "reason" => 1, "selected_value" => {"stringList" => ["a", "b", "c"]}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0}}, {"key" => "feature-flag.integer", "type" => "FEATURE_FLAG", "value" => 3, "value_type" => "int", "count" => 2, "reason" => 2, "selected_value" => {"int" => 3}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 1}}, {"key" => "feature-flag.weighted", "type" => "FEATURE_FLAG", "value" => 2, "value_type" => "int", "count" => 1, "reason" => 3, "selected_value" => {"int" => 2}, "summary" => {"config_row_index" => 0, "conditional_value_index" => 0, "weighted_value_index" => 2}}], endpoint: "/api/v1/telemetry")
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
# reports example contexts
|
|
26
32
|
def test_reports_example_contexts
|
|
27
|
-
|
|
33
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:example_contexts, {})
|
|
34
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :example_contexts, {"user" => {"name" => "michael", "age" => 38, "key" => "michael:1234"}, "device" => {"mobile" => false}, "team" => {"id" => 3.5}}, contexts: {})
|
|
35
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :example_contexts, {"user" => {"name" => "michael", "age" => 38, "key" => "michael:1234"}, "device" => {"mobile" => false}, "team" => {"id" => 3.5}}, endpoint: "/api/v1/telemetry")
|
|
28
36
|
end
|
|
29
37
|
|
|
30
38
|
# example contexts without key are not reported
|
|
31
39
|
def test_example_contexts_without_key_are_not_reported
|
|
32
|
-
|
|
40
|
+
aggregator = IntegrationTestHelpers.build_aggregator(:example_contexts, {})
|
|
41
|
+
IntegrationTestHelpers.feed_aggregator(aggregator, :example_contexts, {"user" => {"name" => "michael", "age" => 38}, "device" => {"mobile" => false}, "team" => {"id" => 3.5}}, contexts: {})
|
|
42
|
+
IntegrationTestHelpers.assert_aggregator_post(aggregator, :example_contexts, nil, endpoint: "/api/v1/telemetry")
|
|
33
43
|
end
|
|
34
44
|
end
|