quonfig 0.0.9 → 0.0.11

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +4 -4
  4. data/lib/quonfig/evaluation_details.rb +60 -0
  5. data/lib/quonfig/options.rb +37 -16
  6. data/lib/quonfig/sse_config_client.rb +1 -1
  7. data/lib/quonfig/version.rb +5 -0
  8. data/lib/quonfig.rb +2 -1
  9. data/quonfig.gemspec +30 -163
  10. metadata +29 -182
  11. data/.claude/rules/constitution.md +0 -81
  12. data/.claude/rules/git-safety.md +0 -11
  13. data/.claude/rules/issue-tracking.md +0 -13
  14. data/.claude/rules/testing-workflow.md +0 -28
  15. data/.envrc.sample +0 -3
  16. data/.github/CODEOWNERS +0 -2
  17. data/.github/pull_request_template.md +0 -8
  18. data/.github/workflows/release.yml +0 -49
  19. data/.github/workflows/ruby.yml +0 -60
  20. data/.github/workflows/test.yaml +0 -40
  21. data/.rubocop.yml +0 -13
  22. data/.tool-versions +0 -1
  23. data/CLAUDE.md +0 -29
  24. data/CODEOWNERS +0 -1
  25. data/Gemfile +0 -26
  26. data/Gemfile.lock +0 -177
  27. data/Rakefile +0 -64
  28. data/VERSION +0 -1
  29. data/dev/allocation_stats +0 -60
  30. data/dev/benchmark +0 -40
  31. data/dev/console +0 -12
  32. data/dev/script_setup.rb +0 -18
  33. data/test/fixtures/datafile.json +0 -87
  34. data/test/integration/test_context_precedence.rb +0 -112
  35. data/test/integration/test_datadir_environment.rb +0 -54
  36. data/test/integration/test_dev_overrides.rb +0 -40
  37. data/test/integration/test_enabled.rb +0 -478
  38. data/test/integration/test_enabled_with_contexts.rb +0 -64
  39. data/test/integration/test_get.rb +0 -136
  40. data/test/integration/test_get_feature_flag.rb +0 -28
  41. data/test/integration/test_get_or_raise.rb +0 -60
  42. data/test/integration/test_get_weighted_values.rb +0 -34
  43. data/test/integration/test_helpers.rb +0 -667
  44. data/test/integration/test_helpers_test.rb +0 -73
  45. data/test/integration/test_post.rb +0 -44
  46. data/test/integration/test_telemetry.rb +0 -170
  47. data/test/support/common_helpers.rb +0 -106
  48. data/test/support/mock_base_client.rb +0 -27
  49. data/test/support/mock_config_loader.rb +0 -1
  50. data/test/test_bound_client.rb +0 -109
  51. data/test/test_caching_http_connection.rb +0 -218
  52. data/test/test_client.rb +0 -255
  53. data/test/test_client_network_mode.rb +0 -136
  54. data/test/test_client_telemetry.rb +0 -175
  55. data/test/test_config_loader.rb +0 -70
  56. data/test/test_context.rb +0 -139
  57. data/test/test_context_shape.rb +0 -37
  58. data/test/test_context_shape_aggregator.rb +0 -126
  59. data/test/test_datadir.rb +0 -203
  60. data/test/test_dev_context.rb +0 -163
  61. data/test/test_duration.rb +0 -37
  62. data/test/test_encryption.rb +0 -16
  63. data/test/test_evaluation_summaries_aggregator.rb +0 -180
  64. data/test/test_evaluator.rb +0 -285
  65. data/test/test_example_contexts_aggregator.rb +0 -119
  66. data/test/test_exponential_backoff.rb +0 -44
  67. data/test/test_fixed_size_hash.rb +0 -119
  68. data/test/test_helper.rb +0 -17
  69. data/test/test_http_connection.rb +0 -79
  70. data/test/test_internal_logger.rb +0 -34
  71. data/test/test_options.rb +0 -167
  72. data/test/test_rate_limit_cache.rb +0 -44
  73. data/test/test_reason.rb +0 -79
  74. data/test/test_rename.rb +0 -65
  75. data/test/test_resolver.rb +0 -291
  76. data/test/test_semantic_logger_filter.rb +0 -144
  77. data/test/test_semver.rb +0 -108
  78. data/test/test_should_log.rb +0 -186
  79. data/test/test_sse_config_client.rb +0 -297
  80. data/test/test_stdlib_formatter.rb +0 -195
  81. data/test/test_telemetry_reporter.rb +0 -209
  82. data/test/test_typed_getters.rb +0 -131
  83. data/test/test_types.rb +0 -141
  84. data/test/test_weighted_value_resolver.rb +0 -84
data/test/test_datadir.rb DELETED
@@ -1,203 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
- require 'json'
5
- require 'tmpdir'
6
- require 'fileutils'
7
-
8
- # qfg-dk6.18 — Quonfig::Datadir loads JSON config files from a workspace
9
- # directory and produces a ConfigEnvelope (and a populated ConfigStore) for
10
- # offline / datadir mode. Mirrors sdk-node/src/datadir.ts.
11
- class TestDatadir < Minitest::Test
12
- CONFIG_SUBDIRS = %w[configs feature-flags segments schemas log-levels].freeze
13
-
14
- def setup
15
- @tmpdir = Dir.mktmpdir('quonfig-datadir-test')
16
- CONFIG_SUBDIRS.each { |sub| FileUtils.mkdir_p(File.join(@tmpdir, sub)) }
17
- File.write(File.join(@tmpdir, 'quonfig.json'), JSON.generate({ environments: %w[Production Staging] }))
18
- end
19
-
20
- def teardown
21
- FileUtils.remove_entry(@tmpdir) if @tmpdir && Dir.exist?(@tmpdir)
22
- ENV.delete('QUONFIG_ENVIRONMENT')
23
- end
24
-
25
- def write_config(subdir, filename, body)
26
- File.write(File.join(@tmpdir, subdir, filename), JSON.generate(body))
27
- end
28
-
29
- def sample_config(key, environment_id: 'Production')
30
- {
31
- 'id' => '111',
32
- 'key' => key,
33
- 'type' => 'config',
34
- 'valueType' => 'string',
35
- 'sendToClientSdk' => false,
36
- 'default' => { 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'string', 'value' => 'hello' } }] },
37
- 'environments' => [{ 'id' => environment_id, 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'string', 'value' => 'env-hello' } }] }]
38
- }
39
- end
40
-
41
- def sample_flag(key)
42
- {
43
- 'id' => '222',
44
- 'key' => key,
45
- 'type' => 'feature_flag',
46
- 'valueType' => 'bool',
47
- 'default' => { 'rules' => [{ 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }] },
48
- 'environments' => []
49
- }
50
- end
51
-
52
- def test_load_envelope_returns_config_envelope
53
- write_config('configs', 'a.config.json', sample_config('a.config'))
54
-
55
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
56
-
57
- assert_instance_of Quonfig::ConfigEnvelope, envelope
58
- end
59
-
60
- def test_load_envelope_reads_configs_from_each_subdir
61
- write_config('configs', 'a.config.json', sample_config('a.config'))
62
- write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
63
- write_config('log-levels', 'c.log.json', sample_config('c.log'))
64
- write_config('segments', 'd.segment.json', sample_config('d.segment'))
65
-
66
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
67
-
68
- keys = envelope.configs.map { |c| c['key'] }
69
- assert_equal %w[a.config c.log d.segment b.flag].sort, keys.sort
70
- end
71
-
72
- def test_load_envelope_skips_non_json_and_missing_subdirs
73
- write_config('configs', 'a.config.json', sample_config('a.config'))
74
- File.write(File.join(@tmpdir, 'configs', 'README.md'), '# ignore me')
75
- FileUtils.rm_rf(File.join(@tmpdir, 'segments'))
76
-
77
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
78
-
79
- keys = envelope.configs.map { |c| c['key'] }
80
- assert_equal ['a.config'], keys
81
- end
82
-
83
- def test_load_envelope_picks_environment_block_by_id
84
- write_config('configs', 'a.config.json', sample_config('a.config', environment_id: 'Production'))
85
-
86
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
87
-
88
- cfg = envelope.configs.first
89
- refute_nil cfg['environment'], 'expected environment field to be populated'
90
- assert_equal 'Production', cfg['environment']['id']
91
- end
92
-
93
- def test_load_envelope_environment_field_nil_when_no_match
94
- write_config('configs', 'a.config.json', sample_config('a.config', environment_id: 'Other'))
95
-
96
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
97
-
98
- cfg = envelope.configs.first
99
- assert_nil cfg['environment']
100
- end
101
-
102
- def test_load_envelope_send_to_client_sdk_true_for_feature_flag
103
- write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
104
-
105
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
106
-
107
- cfg = envelope.configs.first
108
- assert_equal true, cfg['sendToClientSdk'], 'feature_flag must always sendToClientSdk=true'
109
- end
110
-
111
- def test_load_envelope_default_rules_when_missing
112
- raw = sample_config('a.config')
113
- raw.delete('default')
114
- write_config('configs', 'a.config.json', raw)
115
-
116
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
117
-
118
- cfg = envelope.configs.first
119
- assert_equal({ 'rules' => [] }, cfg['default'])
120
- end
121
-
122
- def test_load_envelope_meta_includes_version_and_environment
123
- write_config('configs', 'a.config.json', sample_config('a.config'))
124
-
125
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
126
-
127
- assert_equal "datadir:#{@tmpdir}", envelope.meta['version']
128
- assert_equal 'Production', envelope.meta['environment']
129
- end
130
-
131
- def test_resolve_environment_from_env_var
132
- write_config('configs', 'a.config.json', sample_config('a.config'))
133
- ENV['QUONFIG_ENVIRONMENT'] = 'Production'
134
-
135
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, nil)
136
-
137
- assert_equal 'Production', envelope.meta['environment']
138
- end
139
-
140
- def test_constructor_environment_supersedes_env_var
141
- write_config('configs', 'a.config.json', sample_config('a.config'))
142
- ENV['QUONFIG_ENVIRONMENT'] = 'Staging'
143
-
144
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'Production')
145
-
146
- assert_equal 'Production', envelope.meta['environment']
147
- end
148
-
149
- def test_raises_when_no_environment
150
- err = assert_raises(Quonfig::Errors::MissingEnvironmentError) do
151
- Quonfig::Datadir.load_envelope(@tmpdir, nil)
152
- end
153
- assert_match(/Environment required for datadir mode/, err.message)
154
- end
155
-
156
- def test_raises_when_quonfig_json_missing
157
- File.delete(File.join(@tmpdir, 'quonfig.json'))
158
-
159
- err = assert_raises(ArgumentError) { Quonfig::Datadir.load_envelope(@tmpdir, 'Production') }
160
- assert_match(/missing quonfig\.json/, err.message)
161
- end
162
-
163
- def test_raises_when_environment_not_in_workspace
164
- err = assert_raises(Quonfig::Errors::InvalidEnvironmentError) do
165
- Quonfig::Datadir.load_envelope(@tmpdir, 'NotAnEnv')
166
- end
167
- assert_match(/Environment "NotAnEnv" not found/, err.message)
168
- assert_match(/Production/, err.message)
169
- end
170
-
171
- def test_allows_any_environment_when_quonfig_json_environments_empty
172
- File.write(File.join(@tmpdir, 'quonfig.json'), JSON.generate({ environments: [] }))
173
- write_config('configs', 'a.config.json', sample_config('a.config'))
174
-
175
- envelope = Quonfig::Datadir.load_envelope(@tmpdir, 'AnythingGoes')
176
-
177
- assert_equal 'AnythingGoes', envelope.meta['environment']
178
- end
179
-
180
- def test_load_store_returns_populated_config_store
181
- write_config('configs', 'a.config.json', sample_config('a.config'))
182
- write_config('feature-flags', 'b.flag.json', sample_flag('b.flag'))
183
-
184
- store = Quonfig::Datadir.load_store(@tmpdir, 'Production')
185
-
186
- assert_instance_of Quonfig::ConfigStore, store
187
- assert_equal %w[a.config b.flag].sort, store.keys.sort
188
- assert_equal 'a.config', store.get('a.config')['key']
189
- end
190
-
191
- # Verification against the real integration-test-data fixture (path-based).
192
- # Skips when the sibling repo is not present (e.g. limited CI checkouts).
193
- def test_load_store_with_integration_test_data_fixture
194
- fixture = File.expand_path('../../integration-test-data/data/integration-tests', __dir__)
195
- skip "integration-test-data not present at #{fixture}" unless Dir.exist?(fixture)
196
-
197
- store = Quonfig::Datadir.load_store(fixture, 'Production')
198
-
199
- refute_empty store.keys, 'expected at least one config key from integration-test-data'
200
- assert(store.keys.any? { |k| k.start_with?('a.') } || store.keys.include?('already.in.use'),
201
- "expected to find familiar fixture keys; got: #{store.keys.first(5).inspect}")
202
- end
203
- end
@@ -1,163 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
- require 'json'
5
- require 'tmpdir'
6
- require 'fileutils'
7
-
8
- # qfg-pj0.5 — Dev-context injection. When enable_quonfig_user_context: true
9
- # (or env var QUONFIG_DEV_CONTEXT=true), the SDK reads ~/.quonfig/tokens.json
10
- # (written by `qfg login`) and merges {'quonfig-user' => {'email' => ...}}
11
- # into globalContext. Customer-supplied keys win on collision.
12
- #
13
- # Mirror of sdk-node qfg-pj0.3 / sdk-go qfg-pj0.4.
14
- class TestDevContext < Minitest::Test
15
- def setup
16
- super
17
- @tmphome = Dir.mktmpdir('quonfig-dev-ctx-')
18
- FileUtils.mkdir_p(File.join(@tmphome, '.quonfig'))
19
- @old_home = ENV.fetch('HOME', nil)
20
- ENV['HOME'] = @tmphome
21
- ENV.delete('QUONFIG_DEV_CONTEXT')
22
- end
23
-
24
- def teardown
25
- ENV['HOME'] = @old_home
26
- ENV.delete('QUONFIG_DEV_CONTEXT')
27
- FileUtils.remove_entry(@tmphome) if @tmphome && Dir.exist?(@tmphome)
28
- super
29
- end
30
-
31
- def write_tokens(payload)
32
- File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), JSON.generate(payload))
33
- end
34
-
35
- def global_context_of(client)
36
- client.instance_variable_get(:@global_context)
37
- end
38
-
39
- # 1. RED: injects quonfig-user.email when option enabled and file exists
40
- def test_injects_quonfig_user_email_when_option_enabled
41
- write_tokens(userEmail: 'bob@foo.com', accessToken: 'x', refreshToken: 'y', expiresAt: 0)
42
-
43
- client = Quonfig::Client.new(
44
- Quonfig::Options.new(enable_quonfig_user_context: true),
45
- store: Quonfig::ConfigStore.new
46
- )
47
-
48
- assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
49
- end
50
-
51
- # 2. RED: no-op when option disabled and no env var
52
- def test_no_op_when_option_disabled
53
- write_tokens(userEmail: 'bob@foo.com')
54
-
55
- client = Quonfig::Client.new(
56
- Quonfig::Options.new(global_context: { user: { 'plan' => 'pro' } }),
57
- store: Quonfig::ConfigStore.new
58
- )
59
-
60
- assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
61
- end
62
-
63
- # 3. RED: no-op when option enabled but file missing
64
- def test_no_op_when_file_missing
65
- # No tokens.json written.
66
- client = Quonfig::Client.new(
67
- Quonfig::Options.new(
68
- enable_quonfig_user_context: true,
69
- global_context: { user: { 'plan' => 'pro' } }
70
- ),
71
- store: Quonfig::ConfigStore.new
72
- )
73
-
74
- assert_equal({ user: { 'plan' => 'pro' } }, global_context_of(client))
75
- end
76
-
77
- # 4. RED: no-op when file unparseable; warning emitted; init succeeds
78
- def test_no_op_when_file_unparseable
79
- File.write(File.join(@tmphome, '.quonfig', 'tokens.json'), '{not valid json')
80
-
81
- client = Quonfig::Client.new(
82
- Quonfig::Options.new(enable_quonfig_user_context: true),
83
- store: Quonfig::ConfigStore.new
84
- )
85
-
86
- assert_equal({}, global_context_of(client))
87
- # The dev-context loader emits a warning to stderr that we want to verify.
88
- assert_stderr(['quonfig'])
89
- end
90
-
91
- # 5. RED: customer-supplied quonfig-user keys win on collision
92
- def test_customer_global_context_wins
93
- write_tokens(userEmail: 'bob@foo.com')
94
-
95
- client = Quonfig::Client.new(
96
- Quonfig::Options.new(
97
- enable_quonfig_user_context: true,
98
- global_context: { 'quonfig-user' => { 'email' => 'override@x.com' } }
99
- ),
100
- store: Quonfig::ConfigStore.new
101
- )
102
-
103
- assert_equal({ 'quonfig-user' => { 'email' => 'override@x.com' } }, global_context_of(client))
104
- end
105
-
106
- # 6. RED: env var QUONFIG_DEV_CONTEXT=true enables when option absent
107
- def test_env_var_enables_when_option_absent
108
- write_tokens(userEmail: 'bob@foo.com')
109
- ENV['QUONFIG_DEV_CONTEXT'] = 'true'
110
-
111
- client = Quonfig::Client.new(
112
- Quonfig::Options.new,
113
- store: Quonfig::ConfigStore.new
114
- )
115
-
116
- assert_equal({ 'quonfig-user' => { 'email' => 'bob@foo.com' } }, global_context_of(client))
117
- end
118
-
119
- # 7. RED: integration — rule keyed on quonfig-user.email fires when injected
120
- def test_attribute_reaches_eval_context
121
- write_tokens(userEmail: 'bob@foo.com')
122
-
123
- flag_config = {
124
- 'id' => 'cfg-flag',
125
- 'key' => 'my-flag',
126
- 'type' => 'feature_flag',
127
- 'valueType' => 'bool',
128
- 'sendToClientSdk' => false,
129
- 'default' => {
130
- 'rules' => [
131
- { 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
132
- ]
133
- },
134
- 'environment' => {
135
- 'id' => 'Production',
136
- 'rules' => [
137
- {
138
- 'criteria' => [{
139
- 'propertyName' => 'quonfig-user.email',
140
- 'operator' => 'PROP_IS_ONE_OF',
141
- 'valueToMatch' => { 'type' => 'string_list', 'value' => ['bob@foo.com'] }
142
- }],
143
- 'value' => { 'type' => 'bool', 'value' => true }
144
- },
145
- { 'criteria' => [{ 'operator' => 'ALWAYS_TRUE' }], 'value' => { 'type' => 'bool', 'value' => false } }
146
- ]
147
- }
148
- }
149
-
150
- store = Quonfig::ConfigStore.new
151
- store.set('my-flag', flag_config)
152
-
153
- client = Quonfig::Client.new(
154
- Quonfig::Options.new(
155
- enable_quonfig_user_context: true,
156
- environment: 'Production'
157
- ),
158
- store: store
159
- )
160
-
161
- assert_equal true, client.get_bool('my-flag')
162
- end
163
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class DurationTest < Minitest::Test
6
- MINUTES_IN_SECONDS = 60
7
- HOURS_IN_SECONDS = 60 * MINUTES_IN_SECONDS
8
- DAYS_IN_SECONDS = 24 * HOURS_IN_SECONDS
9
-
10
- TESTS = [
11
- ['PT0M0S', 0],
12
- ['PT6M', 6 * MINUTES_IN_SECONDS],
13
- ['PT90S', 90],
14
- ['P1D', DAYS_IN_SECONDS],
15
- ['PT1.5M', 1.5 * MINUTES_IN_SECONDS],
16
- ['P0.75D', 0.75 * DAYS_IN_SECONDS],
17
- ['PT1M90.3S', MINUTES_IN_SECONDS + 90.3],
18
- ['PT1H', HOURS_IN_SECONDS],
19
- ['PT1.3H', 1.3 * HOURS_IN_SECONDS],
20
- ['P1.5DT1.5M', 1.5 * DAYS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5],
21
- ['P1.5DT1.5H1.5M', 1.5 * DAYS_IN_SECONDS + 1.5 * HOURS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5],
22
- ['P1.5DT1.5H1.5M3.5S', 1.5 * DAYS_IN_SECONDS + 1.5 * HOURS_IN_SECONDS + MINUTES_IN_SECONDS * 1.5 + 3.5],
23
- ['P1DT2H3M4S', DAYS_IN_SECONDS + 2 * HOURS_IN_SECONDS + 3 * MINUTES_IN_SECONDS + 4],
24
- ['PT1H30M', HOURS_IN_SECONDS + 30 * MINUTES_IN_SECONDS],
25
- ['P0.5DT0.25H', 0.5 * DAYS_IN_SECONDS + 0.25 * HOURS_IN_SECONDS],
26
- ['PT15M30S', 15 * MINUTES_IN_SECONDS + 30],
27
- ['PT0.000347222H', 0.000347222 * HOURS_IN_SECONDS],
28
- ['PT23H59M59S', 23 * HOURS_IN_SECONDS + 59 * MINUTES_IN_SECONDS + 59],
29
- ['P0.25DT3.75H', 0.25 * DAYS_IN_SECONDS + 3.75 * HOURS_IN_SECONDS],
30
- ]
31
-
32
- def test_parsing
33
- TESTS.each do |test|
34
- assert_equal test[1], Quonfig::Duration.parse(test[0]), "Failed parsing #{test[0]}"
35
- end
36
- end
37
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestEncryption < Minitest::Test
6
- def test_encryption
7
- secret = Quonfig::Encryption.generate_new_hex_key
8
-
9
- enc = Quonfig::Encryption.new(secret)
10
-
11
- clear_text = "hello world"
12
- encrypted = enc.encrypt(clear_text)
13
- decrypted = enc.decrypt(encrypted)
14
- assert_equal clear_text, decrypted
15
- end
16
- end
@@ -1,180 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'test_helper'
4
-
5
- class TestEvaluationSummariesAggregator < Minitest::Test
6
- def make_aggregator(max_keys: 100)
7
- Quonfig::Telemetry::EvaluationSummariesAggregator.new(max_keys: max_keys)
8
- end
9
-
10
- def record_eval(agg, overrides = {})
11
- defaults = {
12
- config_id: 'c1',
13
- config_key: 'my-test-key',
14
- config_type: 'config',
15
- conditional_value_index: 0,
16
- weighted_value_index: nil,
17
- selected_value: 'hello',
18
- reason: 1
19
- }
20
- agg.record(**defaults.merge(overrides))
21
- end
22
-
23
- def test_record_dedupes_identical_evaluations_into_one_counter
24
- agg = make_aggregator
25
-
26
- record_eval(agg)
27
- record_eval(agg)
28
- record_eval(agg)
29
-
30
- event = agg.drain_event
31
- refute_nil event
32
- summaries = event['summaries']['summaries']
33
- assert_equal 1, summaries.size
34
-
35
- counters = summaries[0]['counters']
36
- assert_equal 1, counters.size
37
- assert_equal 3, counters[0]['count']
38
- end
39
-
40
- def test_record_creates_separate_counters_for_different_rule_indexes
41
- agg = make_aggregator
42
-
43
- record_eval(agg, conditional_value_index: 0)
44
- record_eval(agg, conditional_value_index: 1)
45
- record_eval(agg, conditional_value_index: 1)
46
-
47
- summaries = agg.drain_event['summaries']['summaries']
48
- assert_equal 1, summaries.size
49
-
50
- counters = summaries[0]['counters']
51
- assert_equal 2, counters.size
52
-
53
- by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['count'] }
54
- assert_equal 1, by_idx[0]
55
- assert_equal 2, by_idx[1]
56
- end
57
-
58
- def test_record_groups_by_config_key_and_type
59
- agg = make_aggregator
60
-
61
- record_eval(agg, config_key: 'alpha', config_type: 'config')
62
- record_eval(agg, config_key: 'alpha', config_type: 'config')
63
- record_eval(agg, config_key: 'beta', config_type: 'feature_flag')
64
-
65
- summaries = agg.drain_event['summaries']['summaries']
66
- assert_equal 2, summaries.size
67
-
68
- by_key = summaries.each_with_object({}) { |s, h| h[s['key']] = s }
69
- assert_equal 'config', by_key['alpha']['type']
70
- assert_equal 'feature_flag', by_key['beta']['type']
71
- assert_equal 2, by_key['alpha']['counters'][0]['count']
72
- assert_equal 1, by_key['beta']['counters'][0]['count']
73
- end
74
-
75
- def test_drain_event_nil_when_empty
76
- agg = make_aggregator
77
- assert_nil agg.drain_event
78
- end
79
-
80
- def test_drain_clears_state
81
- agg = make_aggregator
82
- record_eval(agg)
83
-
84
- refute_nil agg.drain_event
85
- assert_nil agg.drain_event, 'second drain with no new records should be nil'
86
- end
87
-
88
- def test_drain_event_wire_shape
89
- agg = make_aggregator
90
-
91
- record_eval(agg,
92
- config_id: 'cid-42',
93
- config_key: 'my-test-key',
94
- config_type: 'config',
95
- conditional_value_index: 1,
96
- weighted_value_index: nil,
97
- selected_value: 'my-test-value',
98
- reason: 2)
99
-
100
- event = agg.drain_event
101
- refute_nil event
102
- assert event.key?('summaries'), 'top-level event key is summaries'
103
-
104
- inner = event['summaries']
105
- assert_kind_of Integer, inner['start']
106
- assert_kind_of Integer, inner['end']
107
- assert inner['end'] >= inner['start']
108
-
109
- summary = inner['summaries'][0]
110
- assert_equal 'my-test-key', summary['key']
111
- assert_equal 'config', summary['type']
112
-
113
- counter = summary['counters'][0]
114
- assert_equal 'cid-42', counter['configId']
115
- assert_equal 1, counter['conditionalValueIndex']
116
- assert_equal 0, counter['configRowIndex']
117
- assert_equal 1, counter['count']
118
- assert_equal 2, counter['reason']
119
- assert_equal({ 'string' => 'my-test-value' }, counter['selectedValue'])
120
- refute counter.key?('weightedValueIndex'),
121
- 'weightedValueIndex omitted when nil'
122
- end
123
-
124
- def test_selected_value_wrapper_keys_match_prefab_shape
125
- agg = make_aggregator
126
-
127
- record_eval(agg, selected_value: true, conditional_value_index: 0)
128
- record_eval(agg, selected_value: 3, conditional_value_index: 1)
129
- record_eval(agg, selected_value: 1.5, conditional_value_index: 2)
130
- record_eval(agg, selected_value: 'hi', conditional_value_index: 3)
131
- record_eval(agg, selected_value: %w[a b], conditional_value_index: 4)
132
-
133
- counters = agg.drain_event['summaries']['summaries'][0]['counters']
134
- by_idx = counters.each_with_object({}) { |c, h| h[c['conditionalValueIndex']] = c['selectedValue'] }
135
-
136
- assert_equal({ 'bool' => true }, by_idx[0])
137
- assert_equal({ 'int' => 3 }, by_idx[1])
138
- assert_equal({ 'double' => 1.5 }, by_idx[2])
139
- assert_equal({ 'string' => 'hi' }, by_idx[3])
140
- assert_equal({ 'stringList' => %w[a b] }, by_idx[4])
141
- end
142
-
143
- def test_weighted_value_index_included_when_present
144
- agg = make_aggregator
145
-
146
- record_eval(agg, weighted_value_index: 2, reason: 3)
147
-
148
- counter = agg.drain_event['summaries']['summaries'][0]['counters'][0]
149
- assert_equal 2, counter['weightedValueIndex']
150
- assert_equal 3, counter['reason']
151
- end
152
-
153
- def test_log_level_evaluations_are_excluded
154
- agg = make_aggregator
155
-
156
- record_eval(agg, config_type: 'log_level')
157
-
158
- assert_nil agg.drain_event
159
- end
160
-
161
- def test_record_caps_at_max_keys
162
- agg = make_aggregator(max_keys: 2)
163
-
164
- record_eval(agg, config_key: 'a')
165
- record_eval(agg, config_key: 'b')
166
- record_eval(agg, config_key: 'c') # dropped — at cap
167
-
168
- summaries = agg.drain_event['summaries']['summaries']
169
- keys = summaries.map { |s| s['key'] }.sort
170
- assert_equal %w[a b], keys
171
- end
172
-
173
- def test_noop_when_max_keys_zero
174
- agg = make_aggregator(max_keys: 0)
175
-
176
- record_eval(agg)
177
-
178
- assert_nil agg.drain_event
179
- end
180
- end