quonfig 0.0.2
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 +7 -0
- data/.claude/rules/constitution.md +81 -0
- data/.claude/rules/git-safety.md +11 -0
- data/.claude/rules/issue-tracking.md +13 -0
- data/.claude/rules/testing-workflow.md +28 -0
- data/.envrc.sample +3 -0
- data/.github/CODEOWNERS +2 -0
- data/.github/pull_request_template.md +8 -0
- data/.github/workflows/push_gem.yml +49 -0
- data/.github/workflows/ruby.yml +60 -0
- data/.github/workflows/test.yaml +40 -0
- data/.rubocop.yml +13 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +301 -0
- data/CLAUDE.md +29 -0
- data/CODEOWNERS +1 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +177 -0
- data/LICENSE.txt +20 -0
- data/README.md +213 -0
- data/Rakefile +64 -0
- data/VERSION +1 -0
- data/dev/allocation_stats +60 -0
- data/dev/benchmark +40 -0
- data/dev/console +12 -0
- data/dev/script_setup.rb +18 -0
- data/lib/quonfig/bound_client.rb +71 -0
- data/lib/quonfig/caching_http_connection.rb +95 -0
- data/lib/quonfig/client.rb +221 -0
- data/lib/quonfig/config_envelope.rb +5 -0
- data/lib/quonfig/config_loader.rb +103 -0
- data/lib/quonfig/config_store.rb +42 -0
- data/lib/quonfig/context.rb +101 -0
- data/lib/quonfig/datadir.rb +101 -0
- data/lib/quonfig/duration.rb +58 -0
- data/lib/quonfig/encryption.rb +74 -0
- data/lib/quonfig/error.rb +6 -0
- data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
- data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
- data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
- data/lib/quonfig/errors/missing_default_error.rb +13 -0
- data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
- data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
- data/lib/quonfig/errors/uninitialized_error.rb +13 -0
- data/lib/quonfig/evaluation.rb +64 -0
- data/lib/quonfig/evaluator.rb +464 -0
- data/lib/quonfig/exponential_backoff.rb +21 -0
- data/lib/quonfig/fixed_size_hash.rb +14 -0
- data/lib/quonfig/http_connection.rb +46 -0
- data/lib/quonfig/internal_logger.rb +173 -0
- data/lib/quonfig/murmer3.rb +50 -0
- data/lib/quonfig/options.rb +194 -0
- data/lib/quonfig/periodic_sync.rb +74 -0
- data/lib/quonfig/quonfig.rb +58 -0
- data/lib/quonfig/rate_limit_cache.rb +41 -0
- data/lib/quonfig/reason.rb +39 -0
- data/lib/quonfig/resolver.rb +42 -0
- data/lib/quonfig/semantic_logger_filter.rb +90 -0
- data/lib/quonfig/semver.rb +132 -0
- data/lib/quonfig/sse_config_client.rb +135 -0
- data/lib/quonfig/time_helpers.rb +7 -0
- data/lib/quonfig/types.rb +56 -0
- data/lib/quonfig/weighted_value_resolver.rb +49 -0
- data/lib/quonfig.rb +57 -0
- data/quonfig.gemspec +149 -0
- data/scripts/generate_integration_tests.rb +362 -0
- data/test/fixtures/datafile.json +87 -0
- data/test/integration/test_context_precedence.rb +194 -0
- data/test/integration/test_datadir_environment.rb +76 -0
- data/test/integration/test_enabled.rb +784 -0
- data/test/integration/test_enabled_with_contexts.rb +94 -0
- data/test/integration/test_get.rb +224 -0
- data/test/integration/test_get_feature_flag.rb +34 -0
- data/test/integration/test_get_or_raise.rb +86 -0
- data/test/integration/test_get_weighted_values.rb +29 -0
- data/test/integration/test_helpers.rb +139 -0
- data/test/integration/test_helpers_test.rb +73 -0
- data/test/integration/test_post.rb +34 -0
- data/test/integration/test_telemetry.rb +114 -0
- data/test/support/common_helpers.rb +106 -0
- data/test/support/mock_base_client.rb +27 -0
- data/test/support/mock_config_loader.rb +1 -0
- data/test/test_bound_client.rb +109 -0
- data/test/test_caching_http_connection.rb +218 -0
- data/test/test_client.rb +255 -0
- data/test/test_config_loader.rb +70 -0
- data/test/test_context.rb +136 -0
- data/test/test_datadir.rb +199 -0
- data/test/test_duration.rb +37 -0
- data/test/test_encryption.rb +16 -0
- data/test/test_evaluator.rb +285 -0
- data/test/test_exponential_backoff.rb +44 -0
- data/test/test_fixed_size_hash.rb +119 -0
- data/test/test_helper.rb +17 -0
- data/test/test_http_connection.rb +79 -0
- data/test/test_internal_logger.rb +34 -0
- data/test/test_options.rb +167 -0
- data/test/test_rate_limit_cache.rb +44 -0
- data/test/test_reason.rb +79 -0
- data/test/test_rename.rb +65 -0
- data/test/test_resolver.rb +144 -0
- data/test/test_semantic_logger_filter.rb +123 -0
- data/test/test_semver.rb +108 -0
- data/test/test_sse_config_client.rb +297 -0
- data/test/test_typed_getters.rb +131 -0
- data/test/test_types.rb +141 -0
- data/test/test_weighted_value_resolver.rb +84 -0
- metadata +311 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# AUTO-GENERATED from integration-test-data/tests/eval/post.yaml.
|
|
4
|
+
# Regenerate with `bundle exec ruby scripts/generate_integration_tests.rb`.
|
|
5
|
+
# Do NOT edit by hand — changes will be overwritten.
|
|
6
|
+
|
|
7
|
+
require 'test_helper'
|
|
8
|
+
require 'integration/test_helpers'
|
|
9
|
+
|
|
10
|
+
class TestPost < Minitest::Test
|
|
11
|
+
def setup
|
|
12
|
+
@store = IntegrationTestHelpers.build_store("post")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# reports context shape aggregation
|
|
16
|
+
def test_reports_context_shape_aggregation
|
|
17
|
+
skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# reports evaluation summary
|
|
21
|
+
def test_reports_evaluation_summary
|
|
22
|
+
skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# reports example contexts
|
|
26
|
+
def test_reports_example_contexts
|
|
27
|
+
skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# example contexts without key are not reported
|
|
31
|
+
def test_example_contexts_without_key_are_not_reported
|
|
32
|
+
skip("post/aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
#
|
|
3
|
+
# AUTO-GENERATED from integration-test-data/tests/eval/telemetry.yaml.
|
|
4
|
+
# Regenerate with `bundle exec ruby scripts/generate_integration_tests.rb`.
|
|
5
|
+
# Do NOT edit by hand — changes will be overwritten.
|
|
6
|
+
|
|
7
|
+
require 'test_helper'
|
|
8
|
+
require 'integration/test_helpers'
|
|
9
|
+
|
|
10
|
+
class TestTelemetry < Minitest::Test
|
|
11
|
+
def setup
|
|
12
|
+
@store = IntegrationTestHelpers.build_store("telemetry")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# reason is STATIC for config with no targeting rules
|
|
16
|
+
def test_reason_is_static_for_config_with_no_targeting_rules
|
|
17
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# reason is STATIC for feature flag with only ALWAYS_TRUE rules
|
|
21
|
+
def test_reason_is_static_for_feature_flag_with_only_always_true_rules
|
|
22
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# reason is TARGETING_MATCH when config has targeting rules but evaluation falls through
|
|
26
|
+
def test_reason_is_targeting_match_when_config_has_targeting_rules_but_evaluation_falls_through
|
|
27
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# reason is TARGETING_MATCH when a targeting rule matches
|
|
31
|
+
def test_reason_is_targeting_match_when_a_targeting_rule_matches
|
|
32
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# reason is SPLIT for weighted value evaluation
|
|
36
|
+
def test_reason_is_split_for_weighted_value_evaluation
|
|
37
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# reason is TARGETING_MATCH for feature flag fallthrough with targeting rules
|
|
41
|
+
def test_reason_is_targeting_match_for_feature_flag_fallthrough_with_targeting_rules
|
|
42
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# evaluation summary deduplicates identical evaluations
|
|
46
|
+
def test_evaluation_summary_deduplicates_identical_evaluations
|
|
47
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# evaluation summary creates separate counters for different rules of same config
|
|
51
|
+
def test_evaluation_summary_creates_separate_counters_for_different_rules_of_same_config
|
|
52
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# evaluation summary groups by config key
|
|
56
|
+
def test_evaluation_summary_groups_by_config_key
|
|
57
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# selectedValue wraps string correctly
|
|
61
|
+
def test_selectedvalue_wraps_string_correctly
|
|
62
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# selectedValue wraps boolean correctly
|
|
66
|
+
def test_selectedvalue_wraps_boolean_correctly
|
|
67
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# selectedValue wraps int correctly
|
|
71
|
+
def test_selectedvalue_wraps_int_correctly
|
|
72
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# selectedValue wraps double correctly
|
|
76
|
+
def test_selectedvalue_wraps_double_correctly
|
|
77
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# selectedValue wraps string list correctly
|
|
81
|
+
def test_selectedvalue_wraps_string_list_correctly
|
|
82
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# context shape merges fields across multiple records
|
|
86
|
+
def test_context_shape_merges_fields_across_multiple_records
|
|
87
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# example contexts deduplicates by key value
|
|
91
|
+
def test_example_contexts_deduplicates_by_key_value
|
|
92
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# telemetry disabled emits nothing
|
|
96
|
+
def test_telemetry_disabled_emits_nothing
|
|
97
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# shapes only mode reports shapes but not examples
|
|
101
|
+
def test_shapes_only_mode_reports_shapes_but_not_examples
|
|
102
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# log level evaluations are excluded from telemetry
|
|
106
|
+
def test_log_level_evaluations_are_excluded_from_telemetry
|
|
107
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# empty context produces no context telemetry
|
|
111
|
+
def test_empty_context_produces_no_context_telemetry
|
|
112
|
+
skip("telemetry aggregator integration not yet wired in sdk-ruby (qfg-dk6.x)")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommonHelpers
|
|
4
|
+
require 'timecop'
|
|
5
|
+
|
|
6
|
+
def setup
|
|
7
|
+
$oldstderr, $stderr = $stderr, StringIO.new
|
|
8
|
+
$logs = StringIO.new
|
|
9
|
+
|
|
10
|
+
if defined?(SemanticLogger)
|
|
11
|
+
SemanticLogger.add_appender(io: $logs)
|
|
12
|
+
SemanticLogger.sync!
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def teardown
|
|
17
|
+
if $logs && !$logs.string.empty?
|
|
18
|
+
log_lines = $logs.string.split("\n").reject do |line|
|
|
19
|
+
line.match(/Quonfig::ConfigClient -- No success loading checkpoints/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if log_lines.size > 0
|
|
23
|
+
$logs = nil
|
|
24
|
+
raise "Unexpected logs. Handle logs with assert_logged\n\n#{log_lines}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# note this skips the output check in environments like rubymine that hijack the output
|
|
29
|
+
if $stderr != $oldstderr && $stderr.respond_to?(:string) && !$stderr.string.empty?
|
|
30
|
+
if !RUBY_VERSION.start_with?('2.')
|
|
31
|
+
# Filter out ld-eventsource frozen string literal warnings in Ruby 3.4+
|
|
32
|
+
stderr_lines = $stderr.string.split("\n").reject do |line|
|
|
33
|
+
line.include?('ld-eventsource') && line.include?('literal string will be frozen in the future')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if !stderr_lines.empty?
|
|
37
|
+
raise "Unexpected stderr. Handle stderr with assert_stderr\n\n#{stderr_lines.join("\n")}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
$stderr = $oldstderr if $oldstderr
|
|
43
|
+
|
|
44
|
+
Timecop.return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_env(key, value, &block)
|
|
48
|
+
old_value = ENV.fetch(key, nil)
|
|
49
|
+
|
|
50
|
+
ENV[key] = value
|
|
51
|
+
block.call
|
|
52
|
+
ensure
|
|
53
|
+
ENV[key] = old_value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
FakeResponse = Struct.new(:status, :body)
|
|
57
|
+
|
|
58
|
+
def wait_for(condition, max_wait: 10, sleep_time: 0.01)
|
|
59
|
+
wait_time = 0
|
|
60
|
+
while !condition.call
|
|
61
|
+
wait_time += sleep_time
|
|
62
|
+
sleep sleep_time
|
|
63
|
+
|
|
64
|
+
raise "Waited #{max_wait} seconds for the condition to be true, but it never was" if wait_time > max_wait
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def context(properties)
|
|
69
|
+
Quonfig::Context.new(properties)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def assert_logged(expected)
|
|
73
|
+
# we do a uniq here because logging can happen in a separate thread so the
|
|
74
|
+
# number of times a log might happen could be slightly variable.
|
|
75
|
+
actuals = $logs.string.split("\n").uniq
|
|
76
|
+
expected.each do |expectation|
|
|
77
|
+
matched = false
|
|
78
|
+
|
|
79
|
+
actuals.each do |actual|
|
|
80
|
+
matched = true if actual.match(expectation)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
assert(matched, "expectation: #{expectation}, got: #{actuals}")
|
|
84
|
+
end
|
|
85
|
+
# mark nil to indicate we handled it
|
|
86
|
+
$logs = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def assert_stderr(expected)
|
|
90
|
+
skip "Cannot verify stderr in current environment" unless $stderr.respond_to?(:string)
|
|
91
|
+
$stderr.string.split("\n").uniq.each do |line|
|
|
92
|
+
matched = false
|
|
93
|
+
|
|
94
|
+
expected.reject! do |expectation|
|
|
95
|
+
matched = true if line.include?(expectation)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
assert(matched, "expectation: #{expected}, got: #{line}")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
assert expected.empty?, "Expected stderr to include: #{expected}, but it did not"
|
|
102
|
+
|
|
103
|
+
# restore since we've handled it
|
|
104
|
+
$stderr = $oldstderr
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minimal stand-in for Quonfig::Client used by Evaluator/ConfigLoader tests.
|
|
4
|
+
# We deliberately keep this tiny so the tests don't depend on the live
|
|
5
|
+
# ConfigStore/Resolver/Evaluator wiring — they exercise their target in
|
|
6
|
+
# isolation.
|
|
7
|
+
class MockBaseClient
|
|
8
|
+
STAGING_ENV_ID = 1
|
|
9
|
+
PRODUCTION_ENV_ID = 2
|
|
10
|
+
TEST_ENV_ID = 3
|
|
11
|
+
|
|
12
|
+
attr_reader :options
|
|
13
|
+
|
|
14
|
+
def initialize(options = Quonfig::Options.new)
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def instance_hash
|
|
19
|
+
'mock-base-client-instance-hash'
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def project_id
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def log; end
|
|
27
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
# BoundClient is a pure delegation wrapper: it forwards typed-getter calls to
|
|
6
|
+
# the underlying client with its bound context. These tests exercise that
|
|
7
|
+
# wrapper with a tiny FakeClient double so they do not depend on the rest of
|
|
8
|
+
# the eval pipeline (which is mid-JSON-migration and not fully green yet).
|
|
9
|
+
class TestBoundClient < Minitest::Test
|
|
10
|
+
# Minimal stand-in for Quonfig::Client that records the context each typed
|
|
11
|
+
# getter was called with. BoundClient only exercises the public typed-getter
|
|
12
|
+
# surface + enabled?, so that's all we need here.
|
|
13
|
+
class FakeClient
|
|
14
|
+
attr_reader :calls
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@calls = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get_string(key, default: Quonfig::NO_DEFAULT_PROVIDED, context: Quonfig::NO_DEFAULT_PROVIDED)
|
|
21
|
+
@calls << [:get_string, key, default, context]
|
|
22
|
+
context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get_int(key, default: Quonfig::NO_DEFAULT_PROVIDED, context: Quonfig::NO_DEFAULT_PROVIDED)
|
|
26
|
+
@calls << [:get_int, key, default, context]
|
|
27
|
+
context
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def enabled?(feature_name, jit_context = Quonfig::NO_DEFAULT_PROVIDED)
|
|
31
|
+
@calls << [:enabled?, feature_name, jit_context]
|
|
32
|
+
jit_context
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_get_string_uses_bound_context
|
|
37
|
+
fake = FakeClient.new
|
|
38
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99' })
|
|
39
|
+
|
|
40
|
+
bound.get_string('my.str')
|
|
41
|
+
|
|
42
|
+
call = fake.calls.last
|
|
43
|
+
assert_equal :get_string, call[0]
|
|
44
|
+
assert_equal 'my.str', call[1]
|
|
45
|
+
assert_equal Quonfig::NO_DEFAULT_PROVIDED, call[2]
|
|
46
|
+
assert_equal({ user: { 'key' => '99' } }, call[3])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def test_enabled_uses_bound_context
|
|
50
|
+
fake = FakeClient.new
|
|
51
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99' })
|
|
52
|
+
|
|
53
|
+
bound.enabled?('my.flag')
|
|
54
|
+
|
|
55
|
+
call = fake.calls.last
|
|
56
|
+
assert_equal :enabled?, call[0]
|
|
57
|
+
assert_equal 'my.flag', call[1]
|
|
58
|
+
assert_equal({ user: { 'key' => '99' } }, call[2])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def test_in_context_returns_new_bound_with_merged_context
|
|
62
|
+
fake = FakeClient.new
|
|
63
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99' })
|
|
64
|
+
|
|
65
|
+
chained = bound.in_context(org: { 'id' => 'acme' })
|
|
66
|
+
|
|
67
|
+
assert_kind_of Quonfig::BoundClient, chained
|
|
68
|
+
refute_same bound, chained,
|
|
69
|
+
'in_context should return a NEW BoundClient, not self'
|
|
70
|
+
|
|
71
|
+
expected = { user: { 'key' => '99' }, org: { 'id' => 'acme' } }
|
|
72
|
+
assert_equal expected, chained.context
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_in_context_merged_context_is_used_on_typed_getter
|
|
76
|
+
fake = FakeClient.new
|
|
77
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99' })
|
|
78
|
+
chained = bound.in_context(org: { 'id' => 'acme' })
|
|
79
|
+
|
|
80
|
+
chained.get_string('my.str')
|
|
81
|
+
|
|
82
|
+
ctx_arg = fake.calls.last[3]
|
|
83
|
+
assert_equal({ user: { 'key' => '99' }, org: { 'id' => 'acme' } }, ctx_arg)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_in_context_later_keys_within_same_named_ctx_override_earlier
|
|
87
|
+
fake = FakeClient.new
|
|
88
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99', 'plan' => 'free' })
|
|
89
|
+
|
|
90
|
+
chained = bound.in_context(user: { 'plan' => 'pro' })
|
|
91
|
+
|
|
92
|
+
# 'plan' overridden; 'key' preserved from parent bound
|
|
93
|
+
assert_equal({ user: { 'key' => '99', 'plan' => 'pro' } }, chained.context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def test_in_context_does_not_mutate_parent_bound_context
|
|
97
|
+
fake = FakeClient.new
|
|
98
|
+
bound = Quonfig::BoundClient.new(fake, user: { 'key' => '99' })
|
|
99
|
+
|
|
100
|
+
bound.in_context(org: { 'id' => 'acme' })
|
|
101
|
+
|
|
102
|
+
assert_equal({ user: { 'key' => '99' } }, bound.context)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def test_bound_client_is_frozen
|
|
106
|
+
bound = Quonfig::BoundClient.new(FakeClient.new, user: { 'key' => '99' })
|
|
107
|
+
assert_predicate bound, :frozen?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'test_helper'
|
|
4
|
+
|
|
5
|
+
module Quonfig
|
|
6
|
+
class CachingHttpConnectionTest < Minitest::Test
|
|
7
|
+
def setup
|
|
8
|
+
@uri = 'https://api.example.com'
|
|
9
|
+
@sdk_key = 'test-key'
|
|
10
|
+
@path = '/some/path'
|
|
11
|
+
|
|
12
|
+
# Reset the cache before each test
|
|
13
|
+
CachingHttpConnection.reset_cache!
|
|
14
|
+
|
|
15
|
+
# Setup the mock HTTP connection
|
|
16
|
+
@http_connection = Minitest::Mock.new
|
|
17
|
+
@http_connection.expect(:uri, @uri)
|
|
18
|
+
|
|
19
|
+
# Stub the HttpConnection constructor
|
|
20
|
+
HttpConnection.stub :new, @http_connection do
|
|
21
|
+
@subject = CachingHttpConnection.new(@uri, @sdk_key)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_caches_responses_with_etag_and_max_age
|
|
26
|
+
response_body = 'response data'
|
|
27
|
+
response = Faraday::Response.new(
|
|
28
|
+
status: 200,
|
|
29
|
+
body: response_body,
|
|
30
|
+
response_headers: {
|
|
31
|
+
'ETag' => 'abc123',
|
|
32
|
+
'Cache-Control' => 'max-age=60'
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Expect two calls to uri (one for each request) and one call to get
|
|
37
|
+
@http_connection.expect(:uri, @uri)
|
|
38
|
+
@http_connection.expect(:get, response, [@path])
|
|
39
|
+
|
|
40
|
+
HttpConnection.stub :new, @http_connection do
|
|
41
|
+
# First request should miss cache
|
|
42
|
+
first_response = @subject.get(@path)
|
|
43
|
+
assert_equal response_body, first_response.body
|
|
44
|
+
assert_equal 'MISS', first_response.headers['X-Cache']
|
|
45
|
+
|
|
46
|
+
# Second request should hit cache
|
|
47
|
+
second_response = @subject.get(@path)
|
|
48
|
+
assert_equal response_body, second_response.body
|
|
49
|
+
assert_equal 'HIT', second_response.headers['X-Cache']
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@http_connection.verify
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_respects_max_age_directive
|
|
56
|
+
response = Faraday::Response.new(
|
|
57
|
+
status: 200,
|
|
58
|
+
body: 'fresh data',
|
|
59
|
+
response_headers: {
|
|
60
|
+
'ETag' => 'abc123',
|
|
61
|
+
'Cache-Control' => 'max-age=60'
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
mock = Minitest::Mock.new
|
|
66
|
+
def mock.uri
|
|
67
|
+
'https://api.example.com'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# First request
|
|
71
|
+
mock.expect(:get, response, [@path])
|
|
72
|
+
# After max-age expires, new request with etag
|
|
73
|
+
mock.expect(:get, response, [@path, { 'If-None-Match' => 'abc123' }])
|
|
74
|
+
|
|
75
|
+
Timecop.freeze do
|
|
76
|
+
subject = CachingHttpConnection.new(@uri, @sdk_key)
|
|
77
|
+
subject.instance_variable_set('@connection', mock)
|
|
78
|
+
|
|
79
|
+
# Initial request
|
|
80
|
+
subject.get(@path)
|
|
81
|
+
|
|
82
|
+
# Within max-age window
|
|
83
|
+
Timecop.travel(59)
|
|
84
|
+
cached_response = subject.get(@path)
|
|
85
|
+
assert_equal 'HIT', cached_response.headers['X-Cache']
|
|
86
|
+
|
|
87
|
+
# After max-age window
|
|
88
|
+
Timecop.travel(61)
|
|
89
|
+
new_response = subject.get(@path)
|
|
90
|
+
assert_equal 'MISS', new_response.headers['X-Cache']
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
mock.verify
|
|
94
|
+
end
|
|
95
|
+
def test_handles_304_not_modified
|
|
96
|
+
initial_response = Faraday::Response.new(
|
|
97
|
+
status: 200,
|
|
98
|
+
body: 'cached data',
|
|
99
|
+
response_headers: { 'ETag' => 'abc123' }
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
not_modified_response = Faraday::Response.new(
|
|
103
|
+
status: 304,
|
|
104
|
+
body: '',
|
|
105
|
+
response_headers: { 'ETag' => 'abc123' }
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
mock = Minitest::Mock.new
|
|
109
|
+
def mock.uri
|
|
110
|
+
'https://api.example.com'
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# First request with single arg
|
|
114
|
+
mock.expect(:get, initial_response, [@path])
|
|
115
|
+
|
|
116
|
+
# Second request with both path and headers
|
|
117
|
+
mock.expect(:get, not_modified_response, [@path, { 'If-None-Match' => 'abc123' }])
|
|
118
|
+
|
|
119
|
+
subject = CachingHttpConnection.new(@uri, @sdk_key)
|
|
120
|
+
subject.instance_variable_set('@connection', mock)
|
|
121
|
+
|
|
122
|
+
# Initial request to populate cache
|
|
123
|
+
first_response = subject.get(@path)
|
|
124
|
+
assert_equal 'cached data', first_response.body
|
|
125
|
+
assert_equal 'MISS', first_response.headers['X-Cache']
|
|
126
|
+
|
|
127
|
+
# Subsequent request gets 304
|
|
128
|
+
cached_response = subject.get(@path)
|
|
129
|
+
assert_equal 'cached data', cached_response.body
|
|
130
|
+
assert_equal 200, cached_response.status
|
|
131
|
+
assert_equal 'HIT', cached_response.headers['X-Cache']
|
|
132
|
+
|
|
133
|
+
mock.verify
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def test_does_not_cache_no_store_responses
|
|
137
|
+
response = Faraday::Response.new(
|
|
138
|
+
status: 200,
|
|
139
|
+
body: 'uncacheable data',
|
|
140
|
+
response_headers: { 'Cache-Control' => 'no-store' }
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
mock = Minitest::Mock.new
|
|
144
|
+
def mock.uri
|
|
145
|
+
'https://api.example.com'
|
|
146
|
+
end
|
|
147
|
+
# Both gets with single arg
|
|
148
|
+
mock.expect(:get, response, [@path])
|
|
149
|
+
mock.expect(:get, response, [@path])
|
|
150
|
+
|
|
151
|
+
subject = CachingHttpConnection.new(@uri, @sdk_key)
|
|
152
|
+
subject.instance_variable_set('@connection', mock)
|
|
153
|
+
|
|
154
|
+
2.times do
|
|
155
|
+
result = subject.get(@path)
|
|
156
|
+
assert_equal 'MISS', result.headers['X-Cache']
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
mock.verify
|
|
160
|
+
end
|
|
161
|
+
def test_cache_is_shared_across_instances
|
|
162
|
+
HttpConnection.stub :new, @http_connection do
|
|
163
|
+
instance1 = CachingHttpConnection.new(@uri, @sdk_key)
|
|
164
|
+
instance2 = CachingHttpConnection.new(@uri, @sdk_key)
|
|
165
|
+
|
|
166
|
+
assert_same instance1.class.cache, instance2.class.cache
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_cache_can_be_reset
|
|
171
|
+
old_cache = CachingHttpConnection.cache
|
|
172
|
+
CachingHttpConnection.reset_cache!
|
|
173
|
+
refute_same CachingHttpConnection.cache, old_cache
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def test_adds_if_none_match_header_when_cached
|
|
177
|
+
# First response to be cached
|
|
178
|
+
initial_response = Faraday::Response.new(
|
|
179
|
+
status: 200,
|
|
180
|
+
body: 'cached data',
|
|
181
|
+
response_headers: { 'ETag' => 'abc123' }
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Second request should have If-None-Match header
|
|
185
|
+
not_modified_response = Faraday::Response.new(
|
|
186
|
+
status: 304,
|
|
187
|
+
body: '',
|
|
188
|
+
response_headers: { 'ETag' => 'abc123' }
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
mock = Minitest::Mock.new
|
|
192
|
+
def mock.uri
|
|
193
|
+
'https://api.example.com'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# First request should not have If-None-Match
|
|
197
|
+
mock.expect(:get, initial_response, [@path])
|
|
198
|
+
|
|
199
|
+
# Second request should have If-None-Match header
|
|
200
|
+
mock.expect(:get, not_modified_response, [@path, { 'If-None-Match' => 'abc123' }])
|
|
201
|
+
|
|
202
|
+
subject = CachingHttpConnection.new(@uri, @sdk_key)
|
|
203
|
+
subject.instance_variable_set('@connection', mock)
|
|
204
|
+
|
|
205
|
+
# Initial request to populate cache
|
|
206
|
+
first_response = subject.get(@path)
|
|
207
|
+
assert_equal 'cached data', first_response.body
|
|
208
|
+
assert_equal 'MISS', first_response.headers['X-Cache']
|
|
209
|
+
|
|
210
|
+
# Second request should use If-None-Match
|
|
211
|
+
cached_response = subject.get(@path)
|
|
212
|
+
assert_equal 'cached data', cached_response.body
|
|
213
|
+
assert_equal 'HIT', cached_response.headers['X-Cache']
|
|
214
|
+
|
|
215
|
+
mock.verify
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|