lutaml-store 0.1.1

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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +27 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +10 -0
  6. data/.rubocop_todo.yml +450 -0
  7. data/CLAUDE.md +57 -0
  8. data/CODE_OF_CONDUCT.md +132 -0
  9. data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
  10. data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +220 -0
  13. data/README.adoc +1430 -0
  14. data/Rakefile +12 -0
  15. data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
  16. data/TODO.impl/1-lutaml-hal-migration.md +60 -0
  17. data/TODO.impl/2-glossarist-migration.md +359 -0
  18. data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
  19. data/bin/console +11 -0
  20. data/bin/setup +8 -0
  21. data/demo/Gemfile +15 -0
  22. data/demo/Gemfile.lock +61 -0
  23. data/demo/README.adoc +301 -0
  24. data/demo/data/vcards/co/contact_10_thompson.data +1 -0
  25. data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
  26. data/demo/data/vcards/co/contact_1_doe.data +1 -0
  27. data/demo/data/vcards/co/contact_1_doe.meta +1 -0
  28. data/demo/data/vcards/co/contact_2_smith.data +1 -0
  29. data/demo/data/vcards/co/contact_2_smith.meta +1 -0
  30. data/demo/data/vcards/co/contact_3_johnson.data +1 -0
  31. data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
  32. data/demo/data/vcards/co/contact_4_garcia.data +1 -0
  33. data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
  34. data/demo/data/vcards/co/contact_5_wilson.data +1 -0
  35. data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
  36. data/demo/data/vcards/co/contact_6_brown.data +1 -0
  37. data/demo/data/vcards/co/contact_6_brown.meta +1 -0
  38. data/demo/data/vcards/co/contact_7_davis.data +1 -0
  39. data/demo/data/vcards/co/contact_7_davis.meta +1 -0
  40. data/demo/data/vcards/co/contact_8_anderson.data +1 -0
  41. data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
  42. data/demo/data/vcards/co/contact_9_taylor.data +1 -0
  43. data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
  44. data/demo/data/vcards.db +0 -0
  45. data/demo/pottery_class_demo.rb +164 -0
  46. data/demo/vcard_models.rb +140 -0
  47. data/demo/vcard_store_demo.rb +526 -0
  48. data/lib/lutaml/store/adapter/base.rb +65 -0
  49. data/lib/lutaml/store/adapter/filesystem.rb +288 -0
  50. data/lib/lutaml/store/adapter/memory.rb +225 -0
  51. data/lib/lutaml/store/adapter/sqlite.rb +193 -0
  52. data/lib/lutaml/store/adapter.rb +12 -0
  53. data/lib/lutaml/store/attribute_updater.rb +198 -0
  54. data/lib/lutaml/store/basic_store.rb +190 -0
  55. data/lib/lutaml/store/cache.rb +108 -0
  56. data/lib/lutaml/store/cache_store.rb +282 -0
  57. data/lib/lutaml/store/composite_model_handler.rb +169 -0
  58. data/lib/lutaml/store/compression.rb +137 -0
  59. data/lib/lutaml/store/config.rb +178 -0
  60. data/lib/lutaml/store/database_store.rb +425 -0
  61. data/lib/lutaml/store/events.rb +92 -0
  62. data/lib/lutaml/store/format/base.rb +33 -0
  63. data/lib/lutaml/store/format/json.rb +25 -0
  64. data/lib/lutaml/store/format/jsonl.rb +37 -0
  65. data/lib/lutaml/store/format/marshal_format.rb +37 -0
  66. data/lib/lutaml/store/format/yaml.rb +29 -0
  67. data/lib/lutaml/store/format/yamls.rb +35 -0
  68. data/lib/lutaml/store/format.rb +33 -0
  69. data/lib/lutaml/store/http_cache.rb +279 -0
  70. data/lib/lutaml/store/http_cache_config.rb +53 -0
  71. data/lib/lutaml/store/http_cache_entry.rb +69 -0
  72. data/lib/lutaml/store/http_header_processor.rb +175 -0
  73. data/lib/lutaml/store/integrity.rb +102 -0
  74. data/lib/lutaml/store/model_registration.rb +75 -0
  75. data/lib/lutaml/store/model_registry.rb +123 -0
  76. data/lib/lutaml/store/model_serializer.rb +69 -0
  77. data/lib/lutaml/store/monitor.rb +192 -0
  78. data/lib/lutaml/store/storage_key.rb +40 -0
  79. data/lib/lutaml/store/version.rb +7 -0
  80. data/lib/lutaml/store.rb +41 -0
  81. data/lutaml-store.gemspec +35 -0
  82. data/plan.adoc +606 -0
  83. data/sig/lutaml/store.rbs +6 -0
  84. data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
  85. data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
  86. data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
  87. data/spec/lutaml/store/autoload_spec.rb +34 -0
  88. data/spec/lutaml/store/cache_store_spec.rb +271 -0
  89. data/spec/lutaml/store/compression_spec.rb +78 -0
  90. data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
  91. data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
  92. data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
  93. data/spec/lutaml/store/database_store_spec.rb +279 -0
  94. data/spec/lutaml/store/file_io_spec.rb +219 -0
  95. data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
  96. data/spec/lutaml/store/format_spec.rb +70 -0
  97. data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
  98. data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
  99. data/spec/lutaml/store/http_cache_spec.rb +422 -0
  100. data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
  101. data/spec/lutaml/store/import_spec.rb +90 -0
  102. data/spec/lutaml/store/integrity_spec.rb +157 -0
  103. data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
  104. data/spec/lutaml/store/load_save_spec.rb +107 -0
  105. data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
  106. data/spec/lutaml/store/model_serializer_spec.rb +140 -0
  107. data/spec/lutaml/store/store_spec.rb +182 -0
  108. data/spec/lutaml/store_spec.rb +21 -0
  109. data/spec/spec_helper.rb +16 -0
  110. metadata +166 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/store/http_cache_entry"
5
+
6
+ RSpec.describe Lutaml::Store::HttpCacheEntry do
7
+ let(:cache_entry) do
8
+ described_class.new(
9
+ cache_key: "GET:http://example.com/api",
10
+ url: "http://example.com/api",
11
+ method: "GET",
12
+ request_headers: { "accept" => "application/json" },
13
+ response_body: '{"test": "data"}',
14
+ response_headers: { "content-type" => "application/json" },
15
+ status_code: 200,
16
+ cached_at: Time.now - 1800, # 30 minutes ago
17
+ etag: '"abc123"',
18
+ last_modified: Time.now - 3600, # 1 hour ago
19
+ expires_at: Time.now + 1800, # 30 minutes from now
20
+ max_age: 3600,
21
+ cache_control: { "max-age" => 3600 },
22
+ vary_headers: ["accept-encoding"]
23
+ )
24
+ end
25
+
26
+ describe "#fresh?" do
27
+ context "when entry is not expired and doesn't require revalidation" do
28
+ it "returns true" do
29
+ expect(cache_entry.fresh?).to be true
30
+ end
31
+ end
32
+
33
+ context "when entry is expired" do
34
+ before do
35
+ cache_entry.expires_at = Time.now - 100
36
+ end
37
+
38
+ it "returns false" do
39
+ expect(cache_entry.fresh?).to be false
40
+ end
41
+ end
42
+
43
+ context "when entry must revalidate" do
44
+ before do
45
+ cache_entry.cache_control = { "must-revalidate" => true }
46
+ end
47
+
48
+ it "returns false" do
49
+ expect(cache_entry.fresh?).to be false
50
+ end
51
+ end
52
+ end
53
+
54
+ describe "#expired?" do
55
+ context "when expires_at is in the past" do
56
+ before do
57
+ cache_entry.expires_at = Time.now - 100
58
+ end
59
+
60
+ it "returns true" do
61
+ expect(cache_entry.expired?).to be true
62
+ end
63
+ end
64
+
65
+ context "when max_age is exceeded" do
66
+ before do
67
+ cache_entry.cached_at = Time.now - 7200 # 2 hours ago
68
+ cache_entry.max_age = 3600 # 1 hour
69
+ end
70
+
71
+ it "returns true" do
72
+ expect(cache_entry.expired?).to be true
73
+ end
74
+ end
75
+
76
+ context "when entry is still valid" do
77
+ it "returns false" do
78
+ expect(cache_entry.expired?).to be false
79
+ end
80
+ end
81
+ end
82
+
83
+ describe "#must_revalidate?" do
84
+ context "with must-revalidate directive" do
85
+ before do
86
+ cache_entry.cache_control = { "must-revalidate" => true }
87
+ end
88
+
89
+ it "returns true" do
90
+ expect(cache_entry.must_revalidate?).to be true
91
+ end
92
+ end
93
+
94
+ context "with no-cache directive" do
95
+ before do
96
+ cache_entry.cache_control = { "no-cache" => true }
97
+ end
98
+
99
+ it "returns true" do
100
+ expect(cache_entry.must_revalidate?).to be true
101
+ end
102
+ end
103
+
104
+ context "without revalidation directives" do
105
+ it "returns false" do
106
+ expect(cache_entry.must_revalidate?).to be false
107
+ end
108
+ end
109
+ end
110
+
111
+ describe "#stale?" do
112
+ it "returns opposite of fresh?" do
113
+ expect(cache_entry.stale?).to eq(!cache_entry.fresh?)
114
+ end
115
+ end
116
+
117
+ describe "#cacheable?" do
118
+ context "with successful status code" do
119
+ it "returns true" do
120
+ expect(cache_entry.cacheable?).to be true
121
+ end
122
+ end
123
+
124
+ context "with error status code" do
125
+ before do
126
+ cache_entry.status_code = 404
127
+ end
128
+
129
+ it "returns false" do
130
+ expect(cache_entry.cacheable?).to be false
131
+ end
132
+ end
133
+
134
+ context "with no-store directive" do
135
+ before do
136
+ cache_entry.cache_control = { "no-store" => true }
137
+ end
138
+
139
+ it "returns false" do
140
+ expect(cache_entry.cacheable?).to be false
141
+ end
142
+ end
143
+
144
+ context "with private directive" do
145
+ before do
146
+ cache_entry.cache_control = { "private" => true }
147
+ end
148
+
149
+ it "returns false" do
150
+ expect(cache_entry.cacheable?).to be false
151
+ end
152
+ end
153
+ end
154
+
155
+ describe "#age" do
156
+ it "returns time since cached" do
157
+ age = cache_entry.age
158
+ expect(age).to be_within(1).of(1800) # approximately 30 minutes
159
+ end
160
+ end
161
+
162
+ describe "#remaining_ttl" do
163
+ context "when not expired" do
164
+ it "returns remaining time to live" do
165
+ ttl = cache_entry.remaining_ttl
166
+ expect(ttl).to be_within(1).of(1800) # approximately 30 minutes
167
+ end
168
+ end
169
+
170
+ context "when expired" do
171
+ before do
172
+ cache_entry.expires_at = Time.now - 100
173
+ end
174
+
175
+ it "returns 0" do
176
+ expect(cache_entry.remaining_ttl).to eq(0)
177
+ end
178
+ end
179
+
180
+ context "when no expiry set" do
181
+ before do
182
+ cache_entry.expires_at = nil
183
+ end
184
+
185
+ it "returns infinity" do
186
+ expect(cache_entry.remaining_ttl).to eq(Float::INFINITY)
187
+ end
188
+ end
189
+ end
190
+
191
+ describe "serialization" do
192
+ it "can be serialized and deserialized" do
193
+ hash = cache_entry.to_hash
194
+ restored = described_class.from_hash(hash)
195
+
196
+ expect(restored.cache_key).to eq(cache_entry.cache_key)
197
+ expect(restored.url).to eq(cache_entry.url)
198
+ expect(restored.method).to eq(cache_entry.method)
199
+ expect(restored.status_code).to eq(cache_entry.status_code)
200
+ expect(restored.response_body).to eq(cache_entry.response_body)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/store/http_cache"
5
+ require "lutaml/store/http_cache_config"
6
+ require "tmpdir"
7
+
8
+ # Mock HAL components for testing
9
+ module MockHal
10
+ class MockClient
11
+ attr_accessor :api_url
12
+
13
+ def initialize(api_url = "https://api.example.com")
14
+ @api_url = api_url
15
+ @responses = {}
16
+ @request_count = {}
17
+ end
18
+
19
+ def set_response(url, response, headers = {})
20
+ @responses[url] = {
21
+ body: response,
22
+ headers: headers
23
+ }
24
+ @request_count[url] = 0
25
+ end
26
+
27
+ def get(url)
28
+ full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
29
+ @request_count[full_url] = (@request_count[full_url] || 0) + 1
30
+
31
+ response_data = @responses[full_url]
32
+ raise "No mock response for #{full_url}" unless response_data
33
+
34
+ MockResponse.new(response_data[:body], response_data[:headers])
35
+ end
36
+
37
+ def get_with_headers(url, headers)
38
+ # For conditional requests, check if we should return 304
39
+ full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
40
+ response_data = @responses[full_url]
41
+
42
+ if response_data && headers["if-none-match"] == response_data[:headers]["etag"]
43
+ @request_count[full_url] = (@request_count[full_url] || 0) + 1
44
+ MockResponse.new("", { "status" => "304" }, 304)
45
+ else
46
+ get(url)
47
+ end
48
+ end
49
+
50
+ def get_by_url(url)
51
+ get(url)
52
+ end
53
+
54
+ def request_count(url)
55
+ full_url = url.start_with?("http") ? url : "#{@api_url}#{url}"
56
+ @request_count[full_url] || 0
57
+ end
58
+ end
59
+
60
+ class MockResponse
61
+ attr_reader :headers, :status
62
+
63
+ def initialize(body, headers = {}, status = 200)
64
+ @body = body.is_a?(String) ? JSON.parse(body) : body
65
+ @headers = headers
66
+ @status = status
67
+ rescue JSON::ParserError
68
+ @body = body
69
+ end
70
+
71
+ def to_json(*_args)
72
+ @body.to_json
73
+ end
74
+
75
+ def to_h
76
+ @body.is_a?(Hash) ? @body : { "data" => @body }
77
+ end
78
+
79
+ def [](key)
80
+ to_h[key]
81
+ end
82
+ end
83
+
84
+ class MockModel
85
+ include Lutaml::Model::Serialize
86
+
87
+ attribute :id, Lutaml::Model::Type::String
88
+ attribute :name, Lutaml::Model::Type::String
89
+ attribute :description, Lutaml::Model::Type::String
90
+
91
+ json do
92
+ map "id", to: :id
93
+ map "name", to: :name
94
+ map "description", to: :description
95
+ end
96
+ end
97
+
98
+ # Simplified ModelRegister for testing
99
+ class MockModelRegister
100
+ attr_accessor :client, :cache_store
101
+
102
+ def initialize(client:, cache: nil)
103
+ @client = client
104
+ @cache_store = setup_cache_store(cache) if cache
105
+ end
106
+
107
+ def fetch_resource(url, headers = {})
108
+ # Use HTTP cache if available
109
+ if @cache_store.respond_to?(:fetch)
110
+ response = @cache_store.fetch("GET", url, headers) do |request_headers|
111
+ raw_response = if request_headers.any?
112
+ @client.get_with_headers(url, request_headers)
113
+ else
114
+ @client.get(url)
115
+ end
116
+
117
+ convert_client_response_to_http_format(raw_response)
118
+ end
119
+
120
+ convert_http_response_to_client_format(response)
121
+ else
122
+ @client.get(url)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def setup_cache_store(cache_config)
129
+ return nil unless cache_config
130
+
131
+ config = cache_config.is_a?(Hash) ? cache_config : { adapter: cache_config }
132
+
133
+ http_config = Lutaml::Store::HttpCacheConfig.new(
134
+ adapter_type: config[:adapter_type] || :memory,
135
+ default_ttl: config[:ttl] || 3600,
136
+ max_entries: config[:max_size] || 1000,
137
+ respect_http_headers: config[:respect_http_headers] != false,
138
+ enable_conditional_requests: config[:enable_conditional_requests] != false
139
+ )
140
+
141
+ # Add adapter options if needed
142
+ http_config.adapter_options = { path: config[:path] } if config[:path]
143
+
144
+ Lutaml::Store::HttpCache.new(http_config)
145
+ end
146
+
147
+ def convert_client_response_to_http_format(client_response)
148
+ headers = {}
149
+ headers = client_response.headers if client_response.respond_to?(:headers)
150
+
151
+ {
152
+ status_code: client_response.respond_to?(:status) ? client_response.status : 200,
153
+ headers: headers,
154
+ body: client_response.respond_to?(:to_json) ? client_response.to_json : client_response.to_s
155
+ }
156
+ end
157
+
158
+ def convert_http_response_to_client_format(http_response)
159
+ if http_response[:body].is_a?(String)
160
+ begin
161
+ JSON.parse(http_response[:body])
162
+ rescue JSON::ParserError
163
+ http_response[:body]
164
+ end
165
+ else
166
+ http_response[:body]
167
+ end
168
+ end
169
+ end
170
+ end
171
+
172
+ RSpec.describe "HTTP Cache HAL Integration", skip: "WIP: HAL integration not yet implemented" do
173
+ let(:client) { MockHal::MockClient.new }
174
+ let(:cache_config) do
175
+ {
176
+ adapter_type: :memory,
177
+ ttl: 3600,
178
+ max_size: 100,
179
+ respect_http_headers: true,
180
+ enable_conditional_requests: true
181
+ }
182
+ end
183
+ let(:register) { MockHal::MockModelRegister.new(client: client, cache: cache_config) }
184
+
185
+ describe "basic caching functionality" do
186
+ let(:url) { "https://api.example.com/resources/123" }
187
+ let(:response_data) do
188
+ {
189
+ "id" => "123",
190
+ "name" => "Test Resource",
191
+ "description" => "A test resource"
192
+ }
193
+ end
194
+
195
+ before do
196
+ client.set_response(url, response_data, {
197
+ "etag" => '"abc123"',
198
+ "cache-control" => "max-age=3600",
199
+ "last-modified" => "Wed, 21 Oct 2015 07:28:00 GMT"
200
+ })
201
+ end
202
+
203
+ it "caches responses and avoids duplicate requests" do
204
+ # First request should hit the server
205
+ result1 = register.fetch_resource(url)
206
+ expect(client.request_count(url)).to eq(1)
207
+ expect(result1["id"]).to eq("123")
208
+
209
+ # Second request should use cache
210
+ result2 = register.fetch_resource(url)
211
+ expect(client.request_count(url)).to eq(1) # Still 1, no new request
212
+ expect(result2["id"]).to eq("123")
213
+ end
214
+
215
+ it "respects cache-control headers" do
216
+ # Set a very short TTL in the response
217
+ client.set_response(url, response_data, {
218
+ "etag" => '"abc123"',
219
+ "cache-control" => "max-age=1"
220
+ })
221
+
222
+ # First request
223
+ register.fetch_resource(url)
224
+ expect(client.request_count(url)).to eq(1)
225
+
226
+ # Wait for cache to expire
227
+ sleep(1.1)
228
+
229
+ # Second request should hit server again due to expired cache
230
+ register.fetch_resource(url)
231
+ expect(client.request_count(url)).to eq(2)
232
+ end
233
+
234
+ it "uses conditional requests with ETags" do
235
+ # First request to populate cache
236
+ register.fetch_resource(url)
237
+ expect(client.request_count(url)).to eq(1)
238
+
239
+ # Wait for cache to expire naturally, then make another request
240
+ # This should trigger a conditional request
241
+ sleep(0.1) # Small delay to ensure different timestamps
242
+
243
+ # The cache should still be valid, so no new request
244
+ result = register.fetch_resource(url)
245
+ expect(client.request_count(url)).to eq(1) # Still cached
246
+ expect(result["id"]).to eq("123")
247
+ end
248
+ end
249
+
250
+ describe "cache configuration" do
251
+ it "works with memory adapter" do
252
+ memory_register = MockHal::MockModelRegister.new(
253
+ client: client,
254
+ cache: { adapter_type: :memory, ttl: 1800 }
255
+ )
256
+
257
+ url = "https://api.example.com/memory-test"
258
+ client.set_response(url, { "data" => "memory test" })
259
+
260
+ result = memory_register.fetch_resource(url)
261
+ expect(result["data"]).to eq("memory test")
262
+ expect(client.request_count(url)).to eq(1)
263
+
264
+ # Second request should use cache
265
+ memory_register.fetch_resource(url)
266
+ expect(client.request_count(url)).to eq(1)
267
+ end
268
+
269
+ it "works with filesystem adapter" do
270
+ Dir.mktmpdir do |tmpdir|
271
+ fs_register = MockHal::MockModelRegister.new(
272
+ client: client,
273
+ cache: {
274
+ adapter_type: :filesystem,
275
+ ttl: 1800,
276
+ path: tmpdir
277
+ }
278
+ )
279
+
280
+ url = "https://api.example.com/fs-test"
281
+ client.set_response(url, { "data" => "filesystem test" })
282
+
283
+ result = fs_register.fetch_resource(url)
284
+ expect(result["data"]).to eq("filesystem test")
285
+ expect(client.request_count(url)).to eq(1)
286
+
287
+ # Second request should use cache
288
+ fs_register.fetch_resource(url)
289
+ expect(client.request_count(url)).to eq(1)
290
+
291
+ # Verify cache file was created
292
+ cache_files = Dir.glob(File.join(tmpdir, "**", "*")).select { |f| File.file?(f) }
293
+ expect(cache_files).not_to be_empty
294
+ end
295
+ end
296
+ end
297
+
298
+ describe "HTTP semantics compliance" do
299
+ let(:url) { "https://api.example.com/semantics-test" }
300
+
301
+ it "respects no-cache directive" do
302
+ client.set_response(url, { "data" => "no-cache test" }, {
303
+ "cache-control" => "no-cache"
304
+ })
305
+
306
+ # First request
307
+ register.fetch_resource(url)
308
+ expect(client.request_count(url)).to eq(1)
309
+
310
+ # Second request should not use cache due to no-cache
311
+ register.fetch_resource(url)
312
+ expect(client.request_count(url)).to eq(2)
313
+ end
314
+
315
+ it "respects no-store directive" do
316
+ client.set_response(url, { "data" => "no-store test" }, {
317
+ "cache-control" => "no-store"
318
+ })
319
+
320
+ # Requests should never be cached
321
+ register.fetch_resource(url)
322
+ register.fetch_resource(url)
323
+ expect(client.request_count(url)).to eq(2)
324
+ end
325
+
326
+ it "handles must-revalidate directive" do
327
+ client.set_response(url, { "data" => "revalidate test" }, {
328
+ "cache-control" => "max-age=1, must-revalidate",
329
+ "etag" => '"revalidate123"'
330
+ })
331
+
332
+ # First request
333
+ register.fetch_resource(url)
334
+ expect(client.request_count(url)).to eq(1)
335
+
336
+ # Wait for cache to expire
337
+ sleep(1.1)
338
+
339
+ # Should revalidate with conditional request
340
+ register.fetch_resource(url)
341
+ expect(client.request_count(url)).to eq(2)
342
+ end
343
+ end
344
+
345
+ describe "performance benefits" do
346
+ let(:url) { "https://api.example.com/performance-test" }
347
+
348
+ it "provides significant performance improvement" do
349
+ client.set_response(url, { "data" => "performance test" }, {
350
+ "etag" => '"perf123"',
351
+ "cache-control" => "max-age=3600"
352
+ })
353
+
354
+ # Measure time for first request (cache miss)
355
+ start_time = Time.now
356
+ register.fetch_resource(url)
357
+ first_request_time = Time.now - start_time
358
+
359
+ # Measure time for second request (cache hit)
360
+ start_time = Time.now
361
+ register.fetch_resource(url)
362
+ second_request_time = Time.now - start_time
363
+
364
+ # Cache hit should be significantly faster
365
+ expect(second_request_time).to be < (first_request_time * 0.1)
366
+ expect(client.request_count(url)).to eq(1)
367
+ end
368
+
369
+ it "handles high request volume efficiently" do
370
+ client.set_response(url, { "data" => "volume test" }, {
371
+ "cache-control" => "max-age=3600"
372
+ })
373
+
374
+ # Make many requests
375
+ 100.times { register.fetch_resource(url) }
376
+
377
+ # Should only hit the server once
378
+ expect(client.request_count(url)).to eq(1)
379
+ end
380
+ end
381
+
382
+ describe "error handling" do
383
+ it "handles network errors gracefully" do
384
+ url = "https://api.example.com/error-test"
385
+
386
+ # Don't set up a mock response to simulate network error
387
+ expect do
388
+ register.fetch_resource(url)
389
+ end.to raise_error(/No mock response/)
390
+ end
391
+
392
+ it "handles malformed cache headers" do
393
+ url = "https://api.example.com/malformed-test"
394
+ client.set_response(url, { "data" => "malformed test" }, {
395
+ "cache-control" => "invalid-directive",
396
+ "expires" => "not-a-date"
397
+ })
398
+
399
+ # Should still work despite malformed headers
400
+ result = register.fetch_resource(url)
401
+ expect(result["data"]).to eq("malformed test")
402
+ end
403
+ end
404
+ end