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,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/store/http_cache"
5
+
6
+ RSpec.describe Lutaml::Store::HttpCache do
7
+ let(:cache_config) do
8
+ {
9
+ adapter_type: "memory",
10
+ default_ttl: 3600,
11
+ respect_http_headers: true,
12
+ enable_conditional_requests: true
13
+ }
14
+ end
15
+
16
+ let(:http_cache) { described_class.new(cache_config) }
17
+
18
+ describe "#initialize" do
19
+ it "accepts hash configuration" do
20
+ cache = described_class.new(cache_config)
21
+ expect(cache).to be_a(described_class)
22
+ end
23
+
24
+ it "accepts HttpCacheConfig object" do
25
+ config = Lutaml::Store::HttpCacheConfig.new(cache_config)
26
+ cache = described_class.new(config)
27
+ expect(cache).to be_a(described_class)
28
+ end
29
+
30
+ it "validates configuration" do
31
+ invalid_config = { adapter_type: "memory", default_ttl: -1 }
32
+ expect { described_class.new(invalid_config) }.to raise_error(ArgumentError)
33
+ end
34
+ end
35
+
36
+ describe "#fetch" do
37
+ let(:url) { "http://example.com/api" }
38
+ let(:method) { "GET" }
39
+ let(:headers) { { "accept" => "application/json" } }
40
+ let(:response_data) do
41
+ {
42
+ status_code: 200,
43
+ headers: { "content-type" => "application/json", "cache-control" => "max-age=3600" },
44
+ body: '{"test": "data"}'
45
+ }
46
+ end
47
+
48
+ it "requires a block" do
49
+ expect { http_cache.fetch(method, url, headers) }.to raise_error(ArgumentError)
50
+ end
51
+
52
+ context "cache miss" do
53
+ it "calls block and caches response" do
54
+ block_called = false
55
+ result = http_cache.fetch(method, url, headers) do |request_headers|
56
+ block_called = true
57
+ expect(request_headers).to eq(headers)
58
+ response_data
59
+ end
60
+
61
+ expect(block_called).to be true
62
+ expect(result).to eq(response_data)
63
+ end
64
+
65
+ it "stores cacheable responses" do
66
+ http_cache.fetch(method, url, headers) { response_data }
67
+
68
+ # Second request should use cache
69
+ block_called = false
70
+ result = http_cache.fetch(method, url, headers) do
71
+ block_called = true
72
+ raise "Should not be called"
73
+ end
74
+
75
+ expect(block_called).to be false
76
+ expect(result).to eq(response_data)
77
+ end
78
+ end
79
+
80
+ context "cache hit" do
81
+ before do
82
+ http_cache.fetch(method, url, headers) { response_data }
83
+ end
84
+
85
+ it "returns cached response without calling block" do
86
+ block_called = false
87
+ result = http_cache.fetch(method, url, headers) do
88
+ block_called = true
89
+ raise "Should not be called"
90
+ end
91
+
92
+ expect(block_called).to be false
93
+ expect(result).to eq(response_data)
94
+ end
95
+ end
96
+
97
+ context "conditional requests" do
98
+ let(:etag_response) do
99
+ {
100
+ status_code: 200,
101
+ headers: { "etag" => '"abc123"', "cache-control" => "max-age=1" },
102
+ body: "original data"
103
+ }
104
+ end
105
+
106
+ it "makes conditional request for stale entries" do
107
+ # Cache initial response
108
+ http_cache.fetch(method, url, headers) { etag_response }
109
+
110
+ # Wait for entry to become stale
111
+ sleep(1.1)
112
+
113
+ # Should make conditional request
114
+ conditional_request_made = false
115
+ result = http_cache.fetch(method, url, headers) do |request_headers|
116
+ conditional_request_made = true
117
+ expect(request_headers["If-None-Match"]).to eq('"abc123"')
118
+ { status_code: 304, headers: {}, body: "" }
119
+ end
120
+
121
+ expect(conditional_request_made).to be true
122
+ expect(result[:body]).to eq("original data") # Returns cached body
123
+ expect(result[:status_code]).to eq(200) # Returns cached status
124
+ end
125
+
126
+ it "caches new response when content is modified" do
127
+ # Cache initial response
128
+ http_cache.fetch(method, url, headers) { etag_response }
129
+
130
+ # Wait for entry to become stale
131
+ sleep(1.1)
132
+
133
+ new_response = {
134
+ status_code: 200,
135
+ headers: { "etag" => '"def456"' },
136
+ body: "new data"
137
+ }
138
+
139
+ result = http_cache.fetch(method, url, headers) do |request_headers|
140
+ expect(request_headers["If-None-Match"]).to eq('"abc123"')
141
+ new_response
142
+ end
143
+
144
+ expect(result).to eq(new_response)
145
+ end
146
+ end
147
+
148
+ context "uncacheable responses" do
149
+ let(:error_response) do
150
+ {
151
+ status_code: 404,
152
+ headers: {},
153
+ body: "Not Found"
154
+ }
155
+ end
156
+
157
+ it "does not cache error responses" do
158
+ result1 = http_cache.fetch(method, url, headers) { error_response }
159
+ expect(result1).to eq(error_response)
160
+
161
+ # Second request should call block again
162
+ block_called = false
163
+ result2 = http_cache.fetch(method, url, headers) do
164
+ block_called = true
165
+ error_response
166
+ end
167
+
168
+ expect(block_called).to be true
169
+ expect(result2).to eq(error_response)
170
+ end
171
+
172
+ it "does not cache no-store responses" do
173
+ no_store_response = {
174
+ status_code: 200,
175
+ headers: { "cache-control" => "no-store" },
176
+ body: "sensitive data"
177
+ }
178
+
179
+ result1 = http_cache.fetch(method, url, headers) { no_store_response }
180
+ expect(result1).to eq(no_store_response)
181
+
182
+ # Second request should call block again
183
+ block_called = false
184
+ http_cache.fetch(method, url, headers) do
185
+ block_called = true
186
+ no_store_response
187
+ end
188
+
189
+ expect(block_called).to be true
190
+ end
191
+ end
192
+ end
193
+
194
+ describe "#get" do
195
+ let(:url) { "http://example.com/api" }
196
+ let(:method) { "GET" }
197
+ let(:headers) { { "accept" => "application/json" } }
198
+ let(:response_data) do
199
+ {
200
+ status_code: 200,
201
+ headers: { "content-type" => "application/json" },
202
+ body: '{"test": "data"}'
203
+ }
204
+ end
205
+
206
+ it "returns nil for cache miss" do
207
+ result = http_cache.get(method, url, headers)
208
+ expect(result).to be_nil
209
+ end
210
+
211
+ it "returns cached response for cache hit" do
212
+ # Cache a response first
213
+ http_cache.fetch(method, url, headers) { response_data }
214
+
215
+ # Get should return cached response
216
+ result = http_cache.get(method, url, headers)
217
+ expect(result).to eq(response_data)
218
+ end
219
+
220
+ it "returns nil for stale entries" do
221
+ stale_response = {
222
+ status_code: 200,
223
+ headers: { "cache-control" => "max-age=1" },
224
+ body: "data"
225
+ }
226
+
227
+ # Cache a response
228
+ http_cache.fetch(method, url, headers) { stale_response }
229
+
230
+ # Wait for it to become stale
231
+ sleep(1.1)
232
+
233
+ # Get should return nil for stale entry
234
+ result = http_cache.get(method, url, headers)
235
+ expect(result).to be_nil
236
+ end
237
+ end
238
+
239
+ describe "#set" do
240
+ let(:url) { "http://example.com/api" }
241
+ let(:method) { "GET" }
242
+ let(:headers) { { "accept" => "application/json" } }
243
+ let(:response_data) do
244
+ {
245
+ status_code: 200,
246
+ headers: { "content-type" => "application/json" },
247
+ body: '{"test": "data"}'
248
+ }
249
+ end
250
+
251
+ it "stores cacheable response" do
252
+ result = http_cache.set(method, url, headers, response_data)
253
+ expect(result).to eq(response_data)
254
+
255
+ # Verify it was cached
256
+ cached = http_cache.get(method, url, headers)
257
+ expect(cached).to eq(response_data)
258
+ end
259
+
260
+ it "does not store uncacheable response" do
261
+ error_response = {
262
+ status_code: 404,
263
+ headers: {},
264
+ body: "Not Found"
265
+ }
266
+
267
+ result = http_cache.set(method, url, headers, error_response)
268
+ expect(result).to eq(error_response)
269
+
270
+ # Verify it was not cached
271
+ cached = http_cache.get(method, url, headers)
272
+ expect(cached).to be_nil
273
+ end
274
+ end
275
+
276
+ describe "#delete" do
277
+ let(:url) { "http://example.com/api" }
278
+ let(:method) { "GET" }
279
+ let(:headers) { { "accept" => "application/json" } }
280
+ let(:response_data) do
281
+ {
282
+ status_code: 200,
283
+ headers: { "content-type" => "application/json" },
284
+ body: '{"test": "data"}'
285
+ }
286
+ end
287
+
288
+ it "removes cached entry" do
289
+ # Cache a response
290
+ http_cache.set(method, url, headers, response_data)
291
+ expect(http_cache.get(method, url, headers)).to eq(response_data)
292
+
293
+ # Delete it
294
+ http_cache.delete(method, url, headers)
295
+
296
+ # Verify it's gone
297
+ expect(http_cache.get(method, url, headers)).to be_nil
298
+ end
299
+
300
+ it "handles missing entries gracefully" do
301
+ expect { http_cache.delete(method, url, headers) }.not_to raise_error
302
+ end
303
+ end
304
+
305
+ describe "#clear" do
306
+ it "clears all cached entries" do
307
+ # Cache multiple responses
308
+ http_cache.set("GET", "http://example.com/api1", {}, {
309
+ status_code: 200, headers: {}, body: "data1"
310
+ })
311
+ http_cache.set("GET", "http://example.com/api2", {}, {
312
+ status_code: 200, headers: {}, body: "data2"
313
+ })
314
+
315
+ # Clear cache
316
+ http_cache.clear
317
+
318
+ # Verify entries are gone
319
+ expect(http_cache.get("GET", "http://example.com/api1", {})).to be_nil
320
+ expect(http_cache.get("GET", "http://example.com/api2", {})).to be_nil
321
+ end
322
+ end
323
+
324
+ describe "#stats" do
325
+ it "returns cache statistics" do
326
+ stats = http_cache.stats
327
+
328
+ expect(stats).to include(
329
+ adapter_type: "memory",
330
+ config: hash_including(
331
+ default_ttl: 3600,
332
+ max_entries: 10_000,
333
+ respect_http_headers: true,
334
+ enable_conditional_requests: true
335
+ )
336
+ )
337
+ end
338
+ end
339
+
340
+ describe "vary header support" do
341
+ let(:url) { "http://example.com/api" }
342
+ let(:method) { "GET" }
343
+
344
+ it "caches different responses for different vary headers" do
345
+ pending "WIP: vary header caching not yet implemented"
346
+ vary_response = {
347
+ status_code: 200,
348
+ headers: { "vary" => "Accept-Encoding", "content-type" => "application/json" },
349
+ body: '{"compressed": false}'
350
+ }
351
+
352
+ gzip_response = {
353
+ status_code: 200,
354
+ headers: { "vary" => "Accept-Encoding", "content-type" => "application/json" },
355
+ body: '{"compressed": true}'
356
+ }
357
+
358
+ # Cache response for no encoding
359
+ result1 = http_cache.fetch(method, url, {}) { vary_response }
360
+ expect(result1).to eq(vary_response)
361
+
362
+ # Cache response for gzip encoding
363
+ result2 = http_cache.fetch(method, url, { "accept-encoding" => "gzip" }) { gzip_response }
364
+ expect(result2).to eq(gzip_response)
365
+
366
+ # Verify both are cached separately
367
+ cached1 = http_cache.get(method, url, {})
368
+ cached2 = http_cache.get(method, url, { "accept-encoding" => "gzip" })
369
+
370
+ expect(cached1[:body]).to eq('{"compressed": false}')
371
+ expect(cached2[:body]).to eq('{"compressed": true}')
372
+ end
373
+ end
374
+
375
+ describe "query parameter handling" do
376
+ let(:method) { "GET" }
377
+ let(:headers) { {} }
378
+ let(:response_data) do
379
+ {
380
+ status_code: 200,
381
+ headers: {},
382
+ body: "data"
383
+ }
384
+ end
385
+
386
+ it "normalizes query parameter order" do
387
+ url1 = "http://example.com/api?b=2&a=1"
388
+ url2 = "http://example.com/api?a=1&b=2"
389
+
390
+ # Cache response for first URL
391
+ http_cache.set(method, url1, headers, response_data)
392
+
393
+ # Second URL should hit cache (same normalized key)
394
+ cached = http_cache.get(method, url2, headers)
395
+ expect(cached).to eq(response_data)
396
+ end
397
+
398
+ context "with ignored parameters" do
399
+ let(:cache_config_with_ignored_params) do
400
+ {
401
+ adapter_type: "memory",
402
+ default_ttl: 3600,
403
+ ignore_query_params: %w[timestamp nonce]
404
+ }
405
+ end
406
+
407
+ let(:http_cache) { described_class.new(cache_config_with_ignored_params) }
408
+
409
+ it "ignores specified query parameters" do
410
+ url1 = "http://example.com/api?data=value&timestamp=123"
411
+ url2 = "http://example.com/api?data=value&timestamp=456"
412
+
413
+ # Cache response for first URL
414
+ http_cache.set(method, url1, headers, response_data)
415
+
416
+ # Second URL should hit cache (timestamp ignored)
417
+ cached = http_cache.get(method, url2, headers)
418
+ expect(cached).to eq(response_data)
419
+ end
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "lutaml/store/http_header_processor"
5
+
6
+ RSpec.describe Lutaml::Store::HttpHeaderProcessor do
7
+ describe ".parse_cache_control" do
8
+ it "parses simple directives" do
9
+ result = described_class.parse_cache_control("no-cache, no-store")
10
+ expect(result).to eq({
11
+ "no-cache" => true,
12
+ "no-store" => true
13
+ })
14
+ end
15
+
16
+ it "parses directives with values" do
17
+ result = described_class.parse_cache_control("max-age=3600, s-maxage=7200")
18
+ expect(result).to eq({
19
+ "max-age" => 3600,
20
+ "s-maxage" => 7200
21
+ })
22
+ end
23
+
24
+ it "parses quoted values" do
25
+ result = described_class.parse_cache_control('max-age="3600", custom="value"')
26
+ expect(result).to eq({
27
+ "max-age" => 3600,
28
+ "custom" => "value"
29
+ })
30
+ end
31
+
32
+ it "handles nil input" do
33
+ result = described_class.parse_cache_control(nil)
34
+ expect(result).to eq({})
35
+ end
36
+
37
+ it "handles empty string" do
38
+ result = described_class.parse_cache_control("")
39
+ expect(result).to eq({})
40
+ end
41
+ end
42
+
43
+ describe ".calculate_expiry" do
44
+ let(:cached_at) { Time.now }
45
+ let(:default_ttl) { 3600 }
46
+
47
+ context "with Expires header" do
48
+ it "uses Expires header when present" do
49
+ expires_time = Time.now + 7200
50
+ headers = { "expires" => expires_time.httpdate }
51
+
52
+ result = described_class.calculate_expiry(headers, cached_at, default_ttl)
53
+ expect(result).to be_within(1).of(expires_time)
54
+ end
55
+
56
+ it "handles invalid Expires header gracefully" do
57
+ headers = { "expires" => "invalid-date" }
58
+
59
+ result = described_class.calculate_expiry(headers, cached_at, default_ttl)
60
+ expect(result).to be_within(1).of(cached_at + default_ttl)
61
+ end
62
+ end
63
+
64
+ context "with Cache-Control max-age" do
65
+ it "uses max-age when present" do
66
+ headers = { "cache-control" => "max-age=7200" }
67
+
68
+ result = described_class.calculate_expiry(headers, cached_at, default_ttl)
69
+ expect(result).to be_within(1).of(cached_at + 7200)
70
+ end
71
+ end
72
+
73
+ context "with no caching headers" do
74
+ it "falls back to default TTL" do
75
+ headers = {}
76
+
77
+ result = described_class.calculate_expiry(headers, cached_at, default_ttl)
78
+ expect(result).to be_within(1).of(cached_at + default_ttl)
79
+ end
80
+ end
81
+ end
82
+
83
+ describe ".should_cache_response?" do
84
+ it "caches successful responses" do
85
+ result = described_class.should_cache_response?(200, {})
86
+ expect(result).to be true
87
+ end
88
+
89
+ it "does not cache error responses" do
90
+ result = described_class.should_cache_response?(404, {})
91
+ expect(result).to be false
92
+ end
93
+
94
+ it "does not cache responses with no-store" do
95
+ headers = { "cache-control" => "no-store" }
96
+ result = described_class.should_cache_response?(200, headers)
97
+ expect(result).to be false
98
+ end
99
+
100
+ it "does not cache private responses" do
101
+ headers = { "cache-control" => "private" }
102
+ result = described_class.should_cache_response?(200, headers)
103
+ expect(result).to be false
104
+ end
105
+ end
106
+
107
+ describe ".generate_cache_key" do
108
+ it "generates basic cache key" do
109
+ result = described_class.generate_cache_key("GET", "http://example.com/api")
110
+ expect(result).to eq("GET:http://example.com/api")
111
+ end
112
+
113
+ it "normalizes query parameters" do
114
+ result = described_class.generate_cache_key("GET", "http://example.com/api?b=2&a=1")
115
+ expect(result).to eq("GET:http://example.com/api?a=1&b=2")
116
+ end
117
+
118
+ it "filters ignored parameters" do
119
+ result = described_class.generate_cache_key(
120
+ "GET",
121
+ "http://example.com/api?timestamp=123&data=value",
122
+ {},
123
+ ["timestamp"]
124
+ )
125
+ expect(result).to eq("GET:http://example.com/api?data=value")
126
+ end
127
+
128
+ it "includes vary headers in key" do
129
+ vary_headers = { "accept-encoding" => "gzip", "accept-language" => "en" }
130
+ result = described_class.generate_cache_key("GET", "http://example.com/api", vary_headers)
131
+
132
+ expect(result).to start_with("GET:http://example.com/api|")
133
+ expect(result).to include(Digest::SHA256.hexdigest("accept-encoding:gzip|accept-language:en")[0..8])
134
+ end
135
+ end
136
+
137
+ describe ".parse_vary_header" do
138
+ it "parses single header" do
139
+ result = described_class.parse_vary_header("Accept-Encoding")
140
+ expect(result).to eq(["accept-encoding"])
141
+ end
142
+
143
+ it "parses multiple headers" do
144
+ result = described_class.parse_vary_header("Accept-Encoding, Accept-Language, User-Agent")
145
+ expect(result).to eq(%w[accept-encoding accept-language user-agent])
146
+ end
147
+
148
+ it "handles nil input" do
149
+ result = described_class.parse_vary_header(nil)
150
+ expect(result).to eq([])
151
+ end
152
+ end
153
+
154
+ describe ".extract_vary_headers" do
155
+ let(:request_headers) do
156
+ {
157
+ "Accept-Encoding" => "gzip, deflate",
158
+ "Accept-Language" => "en-US,en;q=0.9",
159
+ "User-Agent" => "Mozilla/5.0"
160
+ }
161
+ end
162
+
163
+ it "extracts specified headers" do
164
+ vary_names = %w[accept-encoding accept-language]
165
+ result = described_class.extract_vary_headers(request_headers, vary_names)
166
+
167
+ expect(result).to eq({
168
+ "accept-encoding" => "gzip, deflate",
169
+ "accept-language" => "en-US,en;q=0.9"
170
+ })
171
+ end
172
+
173
+ it "handles case-insensitive matching" do
174
+ vary_names = ["ACCEPT-ENCODING"]
175
+ result = described_class.extract_vary_headers(request_headers, vary_names)
176
+
177
+ expect(result).to eq({
178
+ "accept-encoding" => "gzip, deflate"
179
+ })
180
+ end
181
+
182
+ it "returns empty hash for empty vary names" do
183
+ result = described_class.extract_vary_headers(request_headers, [])
184
+ expect(result).to eq({})
185
+ end
186
+ end
187
+
188
+ describe ".fresh?" do
189
+ let(:cached_at) { Time.now - 1800 } # 30 minutes ago
190
+
191
+ it "returns true when not expired" do
192
+ expires_at = Time.now + 1800 # 30 minutes from now
193
+ result = described_class.fresh?(cached_at, 3600, expires_at)
194
+ expect(result).to be true
195
+ end
196
+
197
+ it "returns false when expires_at is past" do
198
+ expires_at = Time.now - 100
199
+ result = described_class.fresh?(cached_at, 3600, expires_at)
200
+ expect(result).to be false
201
+ end
202
+
203
+ it "returns false when max_age is exceeded" do
204
+ result = described_class.fresh?(cached_at, 1000, nil) # max_age less than age
205
+ expect(result).to be false
206
+ end
207
+ end
208
+
209
+ describe ".build_conditional_headers" do
210
+ let(:cache_entry) do
211
+ double(
212
+ etag: '"abc123"',
213
+ last_modified: Time.parse("2023-01-01 12:00:00 UTC")
214
+ )
215
+ end
216
+
217
+ it "adds If-None-Match for ETag" do
218
+ original_headers = { "accept" => "application/json" }
219
+ result = described_class.build_conditional_headers(cache_entry, original_headers)
220
+
221
+ expect(result["If-None-Match"]).to eq('"abc123"')
222
+ expect(result["accept"]).to eq("application/json")
223
+ end
224
+
225
+ it "adds If-Modified-Since for Last-Modified" do
226
+ original_headers = { "accept" => "application/json" }
227
+ result = described_class.build_conditional_headers(cache_entry, original_headers)
228
+
229
+ expect(result["If-Modified-Since"]).to eq("Sun, 01 Jan 2023 12:00:00 GMT")
230
+ end
231
+
232
+ it "handles missing ETag and Last-Modified" do
233
+ cache_entry_without_headers = double(etag: nil, last_modified: nil)
234
+ original_headers = { "accept" => "application/json" }
235
+
236
+ result = described_class.build_conditional_headers(cache_entry_without_headers, original_headers)
237
+
238
+ expect(result["If-None-Match"]).to be_nil
239
+ expect(result["If-Modified-Since"]).to be_nil
240
+ expect(result["accept"]).to eq("application/json")
241
+ end
242
+ end
243
+
244
+ describe ".parse_last_modified" do
245
+ it "parses valid date" do
246
+ date_string = "Sun, 01 Jan 2023 12:00:00 GMT"
247
+ result = described_class.parse_last_modified(date_string)
248
+
249
+ expect(result).to be_a(Time)
250
+ expect(result.year).to eq(2023)
251
+ end
252
+
253
+ it "handles invalid date gracefully" do
254
+ result = described_class.parse_last_modified("invalid-date")
255
+ expect(result).to be_nil
256
+ end
257
+
258
+ it "handles nil input" do
259
+ result = described_class.parse_last_modified(nil)
260
+ expect(result).to be_nil
261
+ end
262
+ end
263
+
264
+ describe ".has_caching_directives?" do
265
+ it "returns true for Cache-Control header" do
266
+ headers = { "cache-control" => "max-age=3600" }
267
+ expect(described_class.has_caching_directives?(headers)).to be true
268
+ end
269
+
270
+ it "returns true for Expires header" do
271
+ headers = { "expires" => "Sun, 01 Jan 2024 12:00:00 GMT" }
272
+ expect(described_class.has_caching_directives?(headers)).to be true
273
+ end
274
+
275
+ it "returns true for ETag header" do
276
+ headers = { "etag" => '"abc123"' }
277
+ expect(described_class.has_caching_directives?(headers)).to be true
278
+ end
279
+
280
+ it "returns true for Last-Modified header" do
281
+ headers = { "last-modified" => "Sun, 01 Jan 2023 12:00:00 GMT" }
282
+ expect(described_class.has_caching_directives?(headers)).to be true
283
+ end
284
+
285
+ it "returns false for no caching headers" do
286
+ headers = { "content-type" => "application/json" }
287
+ expect(described_class.has_caching_directives?(headers)).to be false
288
+ end
289
+ end
290
+ end