quonfig 0.0.6 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/VERSION +1 -1
  4. data/lib/quonfig/bound_client.rb +26 -0
  5. data/lib/quonfig/client.rb +212 -3
  6. data/lib/quonfig/context.rb +10 -1
  7. data/lib/quonfig/datadir.rb +2 -4
  8. data/lib/quonfig/dev_context.rb +41 -0
  9. data/lib/quonfig/errors/decryption_error.rb +20 -0
  10. data/lib/quonfig/errors/env_var_parse_error.rb +8 -1
  11. data/lib/quonfig/errors/invalid_environment_error.rb +19 -0
  12. data/lib/quonfig/errors/missing_environment_error.rb +18 -0
  13. data/lib/quonfig/evaluator.rb +84 -3
  14. data/lib/quonfig/http_connection.rb +1 -1
  15. data/lib/quonfig/options.rb +4 -1
  16. data/lib/quonfig/resolver.rb +215 -2
  17. data/lib/quonfig/stdlib_formatter.rb +95 -0
  18. data/lib/quonfig/telemetry/context_shape.rb +33 -0
  19. data/lib/quonfig/telemetry/context_shape_aggregator.rb +82 -0
  20. data/lib/quonfig/telemetry/evaluation_summaries_aggregator.rb +119 -0
  21. data/lib/quonfig/telemetry/example_contexts_aggregator.rb +101 -0
  22. data/lib/quonfig/telemetry/telemetry_reporter.rb +212 -0
  23. data/lib/quonfig.rb +10 -0
  24. data/quonfig.gemspec +23 -4
  25. data/test/integration/test_context_precedence.rb +35 -117
  26. data/test/integration/test_datadir_environment.rb +15 -37
  27. data/test/integration/test_dev_overrides.rb +40 -0
  28. data/test/integration/test_enabled.rb +157 -463
  29. data/test/integration/test_enabled_with_contexts.rb +19 -49
  30. data/test/integration/test_get.rb +43 -131
  31. data/test/integration/test_get_feature_flag.rb +7 -13
  32. data/test/integration/test_get_or_raise.rb +19 -45
  33. data/test/integration/test_get_weighted_values.rb +9 -4
  34. data/test/integration/test_helpers.rb +532 -4
  35. data/test/integration/test_post.rb +15 -5
  36. data/test/integration/test_telemetry.rb +77 -21
  37. data/test/test_client_telemetry.rb +175 -0
  38. data/test/test_context.rb +4 -1
  39. data/test/test_context_shape.rb +37 -0
  40. data/test/test_context_shape_aggregator.rb +126 -0
  41. data/test/test_datadir.rb +6 -2
  42. data/test/test_dev_context.rb +163 -0
  43. data/test/test_evaluation_summaries_aggregator.rb +180 -0
  44. data/test/test_example_contexts_aggregator.rb +119 -0
  45. data/test/test_http_connection.rb +1 -1
  46. data/test/test_resolver.rb +149 -2
  47. data/test/test_should_log.rb +186 -0
  48. data/test/test_stdlib_formatter.rb +195 -0
  49. data/test/test_telemetry_reporter.rb +209 -0
  50. metadata +22 -3
  51. data/scripts/generate_integration_tests.rb +0 -362
@@ -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,375 @@ 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
+ # 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
300
+ end
301
+
302
+ # Construct an aggregator. +kind+ is one of :context_shape,
303
+ # :evaluation_summary, :example_contexts (string or symbol). +overrides+
304
+ # mirrors the YAML `client_overrides` block; the only options that
305
+ # affect aggregator output today are `collect_evaluation_summaries`
306
+ # (false → eval-summary aggregator created with max_keys=0 so it noops)
307
+ # and `context_upload_mode` ("shape_only" / "none" → example-contexts
308
+ # aggregator created with max=0; ":none" / ":shape_only" come through
309
+ # as Ruby-symbol strings via js-yaml, which is why we strip the leading
310
+ # colon defensively).
311
+ def self.build_aggregator(kind, overrides = {})
312
+ overrides = (overrides || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v }
313
+ case normalize_kind(kind)
314
+ when :context_shape
315
+ max = aggregator_max_for(overrides, :context_shape)
316
+ Quonfig::Telemetry::ContextShapeAggregator.new(max_shapes: max)
317
+ when :evaluation_summary
318
+ collect = overrides.fetch('collect_evaluation_summaries', true)
319
+ max = collect ? 100_000 : 0
320
+ Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max)
321
+ when :example_contexts
322
+ max = aggregator_max_for(overrides, :example_contexts)
323
+ Quonfig::Telemetry::ExampleContextsAggregator.new(max_contexts: max)
324
+ else
325
+ raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
326
+ end
327
+ end
328
+
329
+ # Feed +data+ through +aggregator+ for the given +kind+. Each kind has
330
+ # its own input shape (see post.yaml / telemetry.yaml):
331
+ # - :context_shape → +data+ is a Hash of named contexts, OR an Array
332
+ # of such hashes (multi-record case).
333
+ # - :evaluation_summary
334
+ # → +data+ is { 'keys' => [...], 'keys_without_context'
335
+ # => [...] }. Each key is resolved against
336
+ # +contexts+ (or empty contexts for the second
337
+ # list), then the EvalResult is recorded.
338
+ # - :example_contexts → same as :context_shape but recorded into the
339
+ # example aggregator.
340
+ def self.feed_aggregator(aggregator, kind, data, contexts: {})
341
+ case normalize_kind(kind)
342
+ when :context_shape
343
+ each_context_record(data) { |rec| aggregator.push(rec) }
344
+ when :example_contexts
345
+ each_context_record(data) { |rec| aggregator.record(Quonfig::Context.new(rec)) }
346
+ when :evaluation_summary
347
+ record_eval_keys(aggregator, data, contexts)
348
+ else
349
+ raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
350
+ end
351
+ end
352
+
353
+ # Drain the aggregator and assert its would-be POST body matches
354
+ # +expected_data+. +endpoint+ is captured from YAML for diagnostics
355
+ # (the Ruby helpers don't actually POST anything — the aggregator's
356
+ # drain_event payload is what the reporter would ship). +expected_data+
357
+ # is YAML-shaped (snake_case `field_types` etc.); we project the
358
+ # aggregator's drain output into that shape so the comparison is
359
+ # apples-to-apples.
360
+ def self.assert_aggregator_post(aggregator, kind, expected_data, endpoint:)
361
+ actual = build_actual_post(aggregator, kind)
362
+
363
+ if expected_data.nil?
364
+ unless actual.nil?
365
+ raise Minitest::Assertion,
366
+ "[#{endpoint}] expected no telemetry POST but aggregator produced #{actual.inspect}"
367
+ end
368
+ return
369
+ end
370
+
371
+ expected_normalized, actual_normalized = align_for_comparison(expected_data, actual, kind)
372
+ actual_normalized = scrub_optional_fields(actual_normalized, expected_normalized, kind)
373
+
374
+ unless actual_normalized == expected_normalized
375
+ raise Minitest::Assertion,
376
+ "[#{endpoint}] aggregator POST mismatch\n expected: #{expected_normalized.inspect}\n actual: #{actual_normalized.inspect}"
377
+ end
378
+ end
379
+
380
+ # Normalize ordering on both sides for comparison. Telemetry payloads
381
+ # are conceptually unordered sets — different SDKs (and different runs
382
+ # within a single SDK if you swap a Hash for a different impl) emit
383
+ # entries in different orders. Sort by a stable key so the comparison
384
+ # is set-equality. Keeps assertion errors readable: both sides print in
385
+ # the same canonical order.
386
+ def self.align_for_comparison(expected, actual, kind)
387
+ case normalize_kind(kind)
388
+ when :evaluation_summary
389
+ sort_key = ->(row) { [row['key'].to_s, (row.dig('summary', 'conditional_value_index') || 0)] }
390
+ [expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
391
+ actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
392
+ when :context_shape
393
+ sort_key = ->(row) { row['name'].to_s }
394
+ [expected.is_a?(Array) ? expected.sort_by(&sort_key) : expected,
395
+ actual.is_a?(Array) ? actual.sort_by(&sort_key) : actual]
396
+ else
397
+ [expected, actual]
398
+ end
399
+ end
400
+ private_class_method :align_for_comparison
401
+
402
+ # Drop fields from +actual+ that the YAML's +expected+ doesn't assert.
403
+ # Today only `selected_value` in eval-summary rows is opt-in (some YAML
404
+ # cases verify the proto-style wrapper, most don't). Index pairwise so
405
+ # the per-row decision lines up.
406
+ def self.scrub_optional_fields(actual, expected, kind)
407
+ return actual unless normalize_kind(kind) == :evaluation_summary
408
+ return actual unless actual.is_a?(Array) && expected.is_a?(Array)
409
+
410
+ actual.each_with_index.map do |row, idx|
411
+ exp_row = expected[idx]
412
+ next row unless row.is_a?(Hash) && exp_row.is_a?(Hash)
413
+ next row if exp_row.key?('selected_value')
414
+
415
+ row.reject { |k, _| k == 'selected_value' }
416
+ end
417
+ end
418
+ private_class_method :scrub_optional_fields
419
+
420
+ # --- aggregator-helper internals ---
421
+
422
+ def self.normalize_kind(kind)
423
+ str = kind.to_s
424
+ str = str.sub(/\A:/, '') # ":shape_only" → "shape_only" if a stray symbol-string sneaks in
425
+ case str
426
+ when 'context_shape' then :context_shape
427
+ when 'evaluation_summary' then :evaluation_summary
428
+ when 'example_contexts' then :example_contexts
429
+ else raise ArgumentError, "Unknown aggregator kind: #{kind.inspect}"
430
+ end
431
+ end
432
+ private_class_method :normalize_kind
433
+
434
+ # Strip a leading `:` so a Ruby-symbol-style YAML scalar (":shape_only"
435
+ # → ":shape_only" string when js-yaml serializes it) compares cleanly.
436
+ def self.strip_symbol(v)
437
+ v.is_a?(String) ? v.sub(/\A:/, '') : v.to_s
438
+ end
439
+ private_class_method :strip_symbol
440
+
441
+ def self.aggregator_max_for(overrides, agg_kind)
442
+ mode = strip_symbol(overrides['context_upload_mode']) if overrides.key?('context_upload_mode')
443
+ return 0 if mode == 'none'
444
+
445
+ case agg_kind
446
+ when :context_shape then 100_000
447
+ when :example_contexts then mode == 'shape_only' ? 0 : 100_000
448
+ end
449
+ end
450
+ private_class_method :aggregator_max_for
451
+
452
+ def self.each_context_record(data)
453
+ return if data.nil?
454
+
455
+ if data.is_a?(Array)
456
+ data.each { |row| yield row if row.is_a?(Hash) && !row.empty? }
457
+ elsif data.is_a?(Hash)
458
+ yield data unless data.empty?
459
+ end
460
+ end
461
+ private_class_method :each_context_record
462
+
463
+ def self.record_eval_keys(aggregator, data, contexts)
464
+ return unless data.is_a?(Hash)
465
+
466
+ keys = data['keys'] || data[:keys] || []
467
+ keys_no_ctx = data['keys_without_context'] || data[:keys_without_context] || []
468
+ store = last_store
469
+ raise '[integration tests] no store cached — call build_store before feed_aggregator' if store.nil?
470
+
471
+ resolver = build_resolver(store)
472
+ ctx = contexts.is_a?(Quonfig::Context) ? contexts : Quonfig::Context.new(contexts || {})
473
+ empty_ctx = Quonfig::Context.new({})
474
+
475
+ self.last_unwrapped_overrides = {}
476
+ Array(keys).each { |key| record_one_eval(aggregator, resolver, store, key, ctx) }
477
+ Array(keys_no_ctx).each { |key| record_one_eval(aggregator, resolver, store, key, empty_ctx) }
478
+ end
479
+ private_class_method :record_eval_keys
480
+
481
+ def self.record_one_eval(aggregator, resolver, store, key, ctx)
482
+ cfg = store.get(key)
483
+ return if cfg.nil?
484
+
485
+ result =
486
+ begin
487
+ resolver.get(key, ctx)
488
+ rescue Quonfig::Errors::MissingDefaultError
489
+ nil
490
+ end
491
+ return if result.nil?
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
508
+ aggregator.record(
509
+ config_id: (cfg[:id] || cfg['id']).to_s,
510
+ config_key: key,
511
+ config_type: (cfg[:type] || cfg['type']).to_s,
512
+ conditional_value_index: result.rule_index,
513
+ weighted_value_index: result.weighted_value_index,
514
+ selected_value: selected_for_telemetry,
515
+ reason: result.wire_reason
516
+ )
517
+ end
518
+ private_class_method :record_one_eval
519
+
520
+ # Project +aggregator+'s drain_event payload onto the YAML's
521
+ # snake_case `expected_data` schema. Returns nil when the aggregator
522
+ # produced nothing (matches YAML's bare `expected_data:` lines).
523
+ def self.build_actual_post(aggregator, kind)
524
+ event = aggregator.drain_event
525
+ return nil if event.nil?
526
+
527
+ case normalize_kind(kind)
528
+ when :context_shape then context_shape_post(event)
529
+ when :evaluation_summary then evaluation_summary_post(event)
530
+ when :example_contexts then example_contexts_post(event)
531
+ end
532
+ end
533
+ private_class_method :build_actual_post
534
+
535
+ def self.context_shape_post(event)
536
+ shapes = event.dig('contextShapes', 'shapes') || []
537
+ return nil if shapes.empty?
538
+
539
+ shapes.map do |shape|
540
+ { 'name' => shape['name'], 'field_types' => shape['fieldTypes'] }
541
+ end
542
+ end
543
+ private_class_method :context_shape_post
544
+
545
+ def self.example_contexts_post(event)
546
+ examples = event.dig('exampleContexts', 'examples') || []
547
+ return nil if examples.empty?
548
+
549
+ # post.yaml expects a single context-set object (the first / only
550
+ # example), keyed by named-context-name. Multiple examples are not
551
+ # exercised in the YAML; if they ever are, this helper still picks
552
+ # the first dedup'd record, matching sdk-node's wire shape.
553
+ contexts = examples.first.dig('contextSet', 'contexts') || []
554
+ contexts.each_with_object({}) do |ctx, acc|
555
+ acc[ctx['type']] = ctx['values']
556
+ end
557
+ end
558
+ private_class_method :example_contexts_post
559
+
560
+ TYPE_LABELS = {
561
+ 'config' => 'CONFIG',
562
+ 'feature_flag' => 'FEATURE_FLAG',
563
+ 'segment' => 'SEGMENT',
564
+ 'log_level' => 'LOG_LEVEL',
565
+ 'schema' => 'SCHEMA'
566
+ }.freeze
567
+ private_constant :TYPE_LABELS
568
+
569
+ def self.evaluation_summary_post(event)
570
+ summaries = event.dig('summaries', 'summaries') || []
571
+ overrides = last_unwrapped_overrides || {}
572
+ rows = []
573
+ summaries.each do |summary|
574
+ type_label = TYPE_LABELS[summary['type'].to_s] || summary['type'].to_s.upcase
575
+ counters = summary['counters'] || []
576
+ counters.each do |counter|
577
+ selected = counter['selectedValue'] || {}
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
588
+ row = {
589
+ 'key' => summary['key'],
590
+ 'type' => type_label,
591
+ 'value' => unwrapped,
592
+ 'value_type' => value_type,
593
+ 'count' => counter['count'],
594
+ 'reason' => counter['reason']
595
+ }
596
+ if counter.key?('selectedValue')
597
+ # YAML test cases that assert `selected_value:` use the raw
598
+ # tagged shape (e.g. {"string" => "hello.world"}). We always
599
+ # emit it; the diff's expected_data will simply not include
600
+ # the field for cases that don't care.
601
+ row['selected_value'] = selected
602
+ end
603
+ summary_block = {
604
+ 'config_row_index' => counter['configRowIndex'],
605
+ 'conditional_value_index' => counter['conditionalValueIndex']
606
+ }
607
+ if counter.key?('weightedValueIndex')
608
+ summary_block['weighted_value_index'] = counter['weightedValueIndex']
609
+ end
610
+ row['summary'] = summary_block
611
+ rows << row
612
+ end
613
+ end
614
+ return nil if rows.empty?
615
+
616
+ # Strip selected_value from rows whose YAML cases don't assert it.
617
+ # We can't know that here, so emit it conditionally based on the
618
+ # caller. The simpler path is to only include selected_value when
619
+ # the YAML case asks for it. To keep this stateless we drop it by
620
+ # default; the helper re-adds it on request via opt-in (see
621
+ # #with_selected_values below). Most cases assert without it.
622
+ rows
623
+ end
624
+ private_class_method :evaluation_summary_post
625
+
626
+ # Decode the proto-style selectedValue wrapper { "<type>" => <val> }
627
+ # into [unwrapped_value, value_type_label]. Mirrors the keys used by
628
+ # EvaluationSummariesAggregator#wrap_selected_value (bool/int/double/
629
+ # string/stringList).
630
+ def self.unwrap_selected(selected)
631
+ return [nil, nil] unless selected.is_a?(Hash) && selected.size == 1
632
+
633
+ key, value = selected.first
634
+ case key
635
+ when 'bool' then [value, 'bool']
636
+ when 'int' then [value, 'int']
637
+ when 'double' then [value, 'double']
638
+ when 'string' then [value, 'string']
639
+ when 'stringList' then [value, 'string_list']
640
+ else [value, key]
641
+ end
642
+ end
643
+ private_class_method :unwrap_selected
644
+
117
645
  # Normalize the raw JSON config on disk into the shape the rest of the
118
646
  # suite expects: one environment row for ENV_ID pulled out of the
119
647
  # 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