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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/rules/constitution.md +81 -0
  3. data/.claude/rules/git-safety.md +11 -0
  4. data/.claude/rules/issue-tracking.md +13 -0
  5. data/.claude/rules/testing-workflow.md +28 -0
  6. data/.envrc.sample +3 -0
  7. data/.github/CODEOWNERS +2 -0
  8. data/.github/pull_request_template.md +8 -0
  9. data/.github/workflows/push_gem.yml +49 -0
  10. data/.github/workflows/ruby.yml +60 -0
  11. data/.github/workflows/test.yaml +40 -0
  12. data/.rubocop.yml +13 -0
  13. data/.tool-versions +1 -0
  14. data/CHANGELOG.md +301 -0
  15. data/CLAUDE.md +29 -0
  16. data/CODEOWNERS +1 -0
  17. data/Gemfile +26 -0
  18. data/Gemfile.lock +177 -0
  19. data/LICENSE.txt +20 -0
  20. data/README.md +213 -0
  21. data/Rakefile +64 -0
  22. data/VERSION +1 -0
  23. data/dev/allocation_stats +60 -0
  24. data/dev/benchmark +40 -0
  25. data/dev/console +12 -0
  26. data/dev/script_setup.rb +18 -0
  27. data/lib/quonfig/bound_client.rb +71 -0
  28. data/lib/quonfig/caching_http_connection.rb +95 -0
  29. data/lib/quonfig/client.rb +221 -0
  30. data/lib/quonfig/config_envelope.rb +5 -0
  31. data/lib/quonfig/config_loader.rb +103 -0
  32. data/lib/quonfig/config_store.rb +42 -0
  33. data/lib/quonfig/context.rb +101 -0
  34. data/lib/quonfig/datadir.rb +101 -0
  35. data/lib/quonfig/duration.rb +58 -0
  36. data/lib/quonfig/encryption.rb +74 -0
  37. data/lib/quonfig/error.rb +6 -0
  38. data/lib/quonfig/errors/env_var_parse_error.rb +11 -0
  39. data/lib/quonfig/errors/initialization_timeout_error.rb +12 -0
  40. data/lib/quonfig/errors/invalid_sdk_key_error.rb +19 -0
  41. data/lib/quonfig/errors/missing_default_error.rb +13 -0
  42. data/lib/quonfig/errors/missing_env_var_error.rb +11 -0
  43. data/lib/quonfig/errors/type_mismatch_error.rb +11 -0
  44. data/lib/quonfig/errors/uninitialized_error.rb +13 -0
  45. data/lib/quonfig/evaluation.rb +64 -0
  46. data/lib/quonfig/evaluator.rb +464 -0
  47. data/lib/quonfig/exponential_backoff.rb +21 -0
  48. data/lib/quonfig/fixed_size_hash.rb +14 -0
  49. data/lib/quonfig/http_connection.rb +46 -0
  50. data/lib/quonfig/internal_logger.rb +173 -0
  51. data/lib/quonfig/murmer3.rb +50 -0
  52. data/lib/quonfig/options.rb +194 -0
  53. data/lib/quonfig/periodic_sync.rb +74 -0
  54. data/lib/quonfig/quonfig.rb +58 -0
  55. data/lib/quonfig/rate_limit_cache.rb +41 -0
  56. data/lib/quonfig/reason.rb +39 -0
  57. data/lib/quonfig/resolver.rb +42 -0
  58. data/lib/quonfig/semantic_logger_filter.rb +90 -0
  59. data/lib/quonfig/semver.rb +132 -0
  60. data/lib/quonfig/sse_config_client.rb +135 -0
  61. data/lib/quonfig/time_helpers.rb +7 -0
  62. data/lib/quonfig/types.rb +56 -0
  63. data/lib/quonfig/weighted_value_resolver.rb +49 -0
  64. data/lib/quonfig.rb +57 -0
  65. data/quonfig.gemspec +149 -0
  66. data/scripts/generate_integration_tests.rb +362 -0
  67. data/test/fixtures/datafile.json +87 -0
  68. data/test/integration/test_context_precedence.rb +194 -0
  69. data/test/integration/test_datadir_environment.rb +76 -0
  70. data/test/integration/test_enabled.rb +784 -0
  71. data/test/integration/test_enabled_with_contexts.rb +94 -0
  72. data/test/integration/test_get.rb +224 -0
  73. data/test/integration/test_get_feature_flag.rb +34 -0
  74. data/test/integration/test_get_or_raise.rb +86 -0
  75. data/test/integration/test_get_weighted_values.rb +29 -0
  76. data/test/integration/test_helpers.rb +139 -0
  77. data/test/integration/test_helpers_test.rb +73 -0
  78. data/test/integration/test_post.rb +34 -0
  79. data/test/integration/test_telemetry.rb +114 -0
  80. data/test/support/common_helpers.rb +106 -0
  81. data/test/support/mock_base_client.rb +27 -0
  82. data/test/support/mock_config_loader.rb +1 -0
  83. data/test/test_bound_client.rb +109 -0
  84. data/test/test_caching_http_connection.rb +218 -0
  85. data/test/test_client.rb +255 -0
  86. data/test/test_config_loader.rb +70 -0
  87. data/test/test_context.rb +136 -0
  88. data/test/test_datadir.rb +199 -0
  89. data/test/test_duration.rb +37 -0
  90. data/test/test_encryption.rb +16 -0
  91. data/test/test_evaluator.rb +285 -0
  92. data/test/test_exponential_backoff.rb +44 -0
  93. data/test/test_fixed_size_hash.rb +119 -0
  94. data/test/test_helper.rb +17 -0
  95. data/test/test_http_connection.rb +79 -0
  96. data/test/test_internal_logger.rb +34 -0
  97. data/test/test_options.rb +167 -0
  98. data/test/test_rate_limit_cache.rb +44 -0
  99. data/test/test_reason.rb +79 -0
  100. data/test/test_rename.rb +65 -0
  101. data/test/test_resolver.rb +144 -0
  102. data/test/test_semantic_logger_filter.rb +123 -0
  103. data/test/test_semver.rb +108 -0
  104. data/test/test_sse_config_client.rb +297 -0
  105. data/test/test_typed_getters.rb +131 -0
  106. data/test/test_types.rb +141 -0
  107. data/test/test_weighted_value_resolver.rb +84 -0
  108. 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