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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/client.rb +109 -2
  5. data/lib/quonfig/context.rb +10 -1
  6. data/lib/quonfig/datadir.rb +2 -4
  7. data/lib/quonfig/errors/decryption_error.rb +20 -0
  8. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  9. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  10. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  11. data/lib/quonfig/evaluator.rb +64 -2
  12. data/lib/quonfig/http_connection.rb +1 -1
  13. data/lib/quonfig/resolver.rb +187 -2
  14. data/lib/quonfig/stdlib_formatter.rb +95 -0
  15. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  16. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  17. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  18. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  19. data/lib/quonfig/telemetry/telemetry_reporter.rb +200 -0
  20. data/lib/quonfig.rb +8 -0
  21. data/quonfig.gemspec +20 -4
  22. data/test/integration/test_context_precedence.rb +35 -117
  23. data/test/integration/test_datadir_environment.rb +15 -37
  24. data/test/integration/test_enabled.rb +157 -463
  25. data/test/integration/test_enabled_with_contexts.rb +19 -49
  26. data/test/integration/test_get.rb +43 -131
  27. data/test/integration/test_get_feature_flag.rb +7 -13
  28. data/test/integration/test_get_or_raise.rb +19 -45
  29. data/test/integration/test_get_weighted_values.rb +9 -4
  30. data/test/integration/test_helpers.rb +499 -4
  31. data/test/integration/test_post.rb +15 -5
  32. data/test/integration/test_telemetry.rb +63 -21
  33. data/test/test_client_telemetry.rb +132 -0
  34. data/test/test_context.rb +4 -1
  35. data/test/test_context_shape.rb +37 -0
  36. data/test/test_context_shape_aggregator.rb +126 -0
  37. data/test/test_datadir.rb +6 -2
  38. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  39. data/test/test_example_contexts_aggregator.rb +119 -0
  40. data/test/test_http_connection.rb +1 -1
  41. data/test/test_resolver.rb +149 -2
  42. data/test/test_should_log.rb +186 -0
  43. data/test/test_stdlib_formatter.rb +195 -0
  44. data/test/test_telemetry_reporter.rb +209 -0
  45. metadata +19 -3
  46. 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; keep the failure message specific so diffs are readable.
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 = resolver.get(key, ctx)
73
- raise Minitest::Assertion, "No evaluation returned for key #{key.inspect}" if result.nil?
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.respond_to?(:unwrapped_value)
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 `bundle exec ruby scripts/generate_integration_tests.rb`.
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
- skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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
- skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
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