middleman-s3_sync 4.6.4 → 4.7.0

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.
@@ -0,0 +1,278 @@
1
+ require 'spec_helper'
2
+
3
+ describe Middleman::S3Sync::IndifferentHash do
4
+ let(:hash) { described_class.new }
5
+
6
+ describe 'string-indifferent key access' do
7
+ it 'allows setting and retrieving values using string keys' do
8
+ hash['foo'] = 'bar'
9
+ expect(hash['foo']).to eq('bar')
10
+ end
11
+
12
+ it 'retrieves string key values using symbol keys' do
13
+ hash['foo'] = 'bar'
14
+ expect(hash[:foo]).to eq('bar')
15
+ end
16
+
17
+ it 'normalizes string keys on retrieval' do
18
+ hash['foo'] = 'value1'
19
+ hash['foo'] = 'value2'
20
+ expect(hash['foo']).to eq('value2')
21
+ expect(hash.keys.count).to eq(1)
22
+ end
23
+ end
24
+
25
+ describe 'symbol-indifferent key access' do
26
+ it 'allows setting and retrieving values using symbol keys' do
27
+ hash[:foo] = 'bar'
28
+ expect(hash[:foo]).to eq('bar')
29
+ end
30
+
31
+ it 'retrieves symbol key values using string keys' do
32
+ hash[:foo] = 'bar'
33
+ expect(hash['foo']).to eq('bar')
34
+ end
35
+
36
+ it 'normalizes symbol keys to strings' do
37
+ hash[:foo] = 'value1'
38
+ hash['foo'] = 'value2'
39
+ expect(hash[:foo]).to eq('value2')
40
+ expect(hash.keys.count).to eq(1)
41
+ end
42
+
43
+ it 'stores keys as strings internally' do
44
+ hash[:foo] = 'bar'
45
+ expect(hash.keys).to eq(['foo'])
46
+ end
47
+ end
48
+
49
+ describe 'dot notation access' do
50
+ it 'allows accessing values using dot notation' do
51
+ hash[:foo] = 'bar'
52
+ expect(hash.foo).to eq('bar')
53
+ end
54
+
55
+ it 'allows setting values using dot notation' do
56
+ hash.foo = 'baz'
57
+ expect(hash[:foo]).to eq('baz')
58
+ end
59
+
60
+ it 'works with string keys' do
61
+ hash['foo'] = 'bar'
62
+ expect(hash.foo).to eq('bar')
63
+ end
64
+
65
+ it 'raises NoMethodError for non-existent keys' do
66
+ expect { hash.nonexistent_key }.to raise_error(NoMethodError)
67
+ end
68
+
69
+ it 'supports respond_to? for existing keys' do
70
+ hash[:foo] = 'bar'
71
+ expect(hash).to respond_to(:foo)
72
+ expect(hash).to respond_to(:foo=)
73
+ end
74
+
75
+ it 'returns false for respond_to? on non-existent keys' do
76
+ expect(hash).not_to respond_to(:nonexistent)
77
+ end
78
+ end
79
+
80
+ describe 'nested hashes with indifferent access' do
81
+ it 'handles nested hash values' do
82
+ hash[:outer] = { inner: 'value' }
83
+ expect(hash[:outer]).to eq({ inner: 'value' })
84
+ end
85
+
86
+ it 'allows nested IndifferentHash instances' do
87
+ inner = described_class.new
88
+ inner[:foo] = 'bar'
89
+ hash[:outer] = inner
90
+
91
+ expect(hash[:outer][:foo]).to eq('bar')
92
+ expect(hash[:outer].foo).to eq('bar')
93
+ end
94
+
95
+ it 'supports multiple levels of nesting' do
96
+ inner = described_class.new
97
+ inner[:level2] = 'deep'
98
+ hash[:level1] = inner
99
+
100
+ expect(hash['level1']['level2']).to eq('deep')
101
+ expect(hash[:level1][:level2]).to eq('deep')
102
+ end
103
+ end
104
+
105
+ describe 'Hash method compatibility' do
106
+ describe '#has_key?' do
107
+ it 'works with string keys' do
108
+ hash['foo'] = 'bar'
109
+ expect(hash.has_key?('foo')).to be true
110
+ expect(hash.has_key?(:foo)).to be true
111
+ end
112
+
113
+ it 'works with symbol keys' do
114
+ hash[:foo] = 'bar'
115
+ expect(hash.has_key?('foo')).to be true
116
+ expect(hash.has_key?(:foo)).to be true
117
+ end
118
+
119
+ it 'returns false for non-existent keys' do
120
+ expect(hash.has_key?('nonexistent')).to be false
121
+ expect(hash.has_key?(:nonexistent)).to be false
122
+ end
123
+ end
124
+
125
+ describe '#key?' do
126
+ it 'is aliased to has_key?' do
127
+ hash[:foo] = 'bar'
128
+ expect(hash.key?('foo')).to be true
129
+ expect(hash.key?(:foo)).to be true
130
+ end
131
+ end
132
+
133
+ describe '#include?' do
134
+ it 'is aliased to has_key?' do
135
+ hash[:foo] = 'bar'
136
+ expect(hash.include?('foo')).to be true
137
+ expect(hash.include?(:foo)).to be true
138
+ end
139
+ end
140
+
141
+ describe '#fetch' do
142
+ it 'fetches values with string keys' do
143
+ hash['foo'] = 'bar'
144
+ expect(hash.fetch('foo')).to eq('bar')
145
+ expect(hash.fetch(:foo)).to eq('bar')
146
+ end
147
+
148
+ it 'fetches values with symbol keys' do
149
+ hash[:foo] = 'bar'
150
+ expect(hash.fetch('foo')).to eq('bar')
151
+ expect(hash.fetch(:foo)).to eq('bar')
152
+ end
153
+
154
+ it 'returns default value for missing keys' do
155
+ expect(hash.fetch('missing', 'default')).to eq('default')
156
+ expect(hash.fetch(:missing, 'default')).to eq('default')
157
+ end
158
+
159
+ it 'calls block for missing keys' do
160
+ result = hash.fetch('missing') { |key| "Key #{key} not found" }
161
+ expect(result).to eq('Key missing not found')
162
+ end
163
+
164
+ it 'raises KeyError when key is missing without default' do
165
+ expect { hash.fetch('missing') }.to raise_error(KeyError)
166
+ expect { hash.fetch(:missing) }.to raise_error(KeyError)
167
+ end
168
+ end
169
+ end
170
+
171
+ describe '.from_hash' do
172
+ it 'creates an IndifferentHash from a regular hash' do
173
+ regular_hash = { 'foo' => 'bar', 'baz' => 'qux' }
174
+ result = described_class.from_hash(regular_hash)
175
+
176
+ expect(result).to be_a(described_class)
177
+ expect(result['foo']).to eq('bar')
178
+ expect(result[:foo]).to eq('bar')
179
+ expect(result['baz']).to eq('qux')
180
+ expect(result[:baz]).to eq('qux')
181
+ end
182
+
183
+ it 'handles hashes with symbol keys' do
184
+ regular_hash = { foo: 'bar', baz: 'qux' }
185
+ result = described_class.from_hash(regular_hash)
186
+
187
+ expect(result[:foo]).to eq('bar')
188
+ expect(result['foo']).to eq('bar')
189
+ end
190
+
191
+ it 'handles empty hashes' do
192
+ result = described_class.from_hash({})
193
+ expect(result).to be_a(described_class)
194
+ expect(result).to be_empty
195
+ end
196
+
197
+ it 'handles mixed string and symbol keys' do
198
+ regular_hash = { 'string_key' => 'value1', :symbol_key => 'value2' }
199
+ result = described_class.from_hash(regular_hash)
200
+
201
+ expect(result['string_key']).to eq('value1')
202
+ expect(result[:string_key]).to eq('value1')
203
+ expect(result['symbol_key']).to eq('value2')
204
+ expect(result[:symbol_key]).to eq('value2')
205
+ end
206
+ end
207
+
208
+ describe 'map gem API compatibility' do
209
+ # These tests ensure no breaking changes compared to the map gem
210
+
211
+ it 'supports basic key-value storage' do
212
+ hash[:key] = 'value'
213
+ expect(hash[:key]).to eq('value')
214
+ expect(hash['key']).to eq('value')
215
+ end
216
+
217
+ it 'supports dot notation like map gem' do
218
+ hash.max_age = 3600
219
+ expect(hash.max_age).to eq(3600)
220
+ expect(hash[:max_age]).to eq(3600)
221
+ end
222
+
223
+ it 'supports fetch with default values' do
224
+ expect(hash.fetch(:missing, 'default')).to eq('default')
225
+ end
226
+
227
+ it 'supports has_key? checks' do
228
+ hash[:present] = 'value'
229
+ expect(hash.has_key?(:present)).to be true
230
+ expect(hash.has_key?('present')).to be true
231
+ expect(hash.has_key?(:absent)).to be false
232
+ end
233
+
234
+ it 'maintains Hash inheritance' do
235
+ expect(hash).to be_a(Hash)
236
+ end
237
+
238
+ it 'supports standard Hash operations' do
239
+ hash[:a] = 1
240
+ hash[:b] = 2
241
+
242
+ expect(hash.keys.sort).to eq(['a', 'b'])
243
+ expect(hash.values.sort).to eq([1, 2])
244
+ expect(hash.size).to eq(2)
245
+ end
246
+
247
+ it 'supports iteration' do
248
+ hash[:a] = 1
249
+ hash[:b] = 2
250
+
251
+ result = []
252
+ hash.each { |k, v| result << [k, v] }
253
+ expect(result).to contain_exactly(['a', 1], ['b', 2])
254
+ end
255
+
256
+ context 'usage in BrowserCachePolicy' do
257
+ it 'supports caching policy access patterns' do
258
+ # Mimics how BrowserCachePolicy uses the hash
259
+ hash[:max_age] = 3600
260
+ hash[:s_maxage] = 7200
261
+ hash[:public] = true
262
+ hash[:no_cache] = false
263
+
264
+ expect(hash.has_key?(:max_age)).to be true
265
+ expect(hash.has_key?('s_maxage')).to be true
266
+ expect(hash.fetch(:public, false)).to be true
267
+ expect(hash.fetch(:private, false)).to be false
268
+ expect(hash.fetch('must_revalidate', false)).to be false
269
+ end
270
+
271
+ it 'supports mixed string and symbol key access like in caching_policy.rb' do
272
+ hash[:max_age] = 3600
273
+ expect(hash['max_age']).to eq(3600)
274
+ expect(hash.max_age).to eq(3600)
275
+ end
276
+ end
277
+ end
278
+ end
@@ -182,6 +182,212 @@ describe Middleman::S3Sync::Resource do
182
182
  end
183
183
  end
184
184
 
185
+ context 'content type detection' do
186
+ context 'when mm_resource provides content_type' do
187
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
188
+
189
+ let(:mm_resource) {
190
+ double(
191
+ destination_path: 'path/to/file.html',
192
+ content_type: 'text/html'
193
+ )
194
+ }
195
+
196
+ before do
197
+ allow(File).to receive(:exist?).with('build/path/to/file.html').and_return(true)
198
+ allow(File).to receive(:read).with('build/path/to/file.html').and_return('content')
199
+ end
200
+
201
+ it 'uses the content_type from mm_resource' do
202
+ expect(resource.content_type).to eq 'text/html'
203
+ end
204
+ end
205
+
206
+ context 'when mm_resource is nil (orphan file)' do
207
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
208
+
209
+ let(:remote) { mock_s3_object('path/to/file.webp') }
210
+
211
+ before do
212
+ resource.full_s3_resource = remote
213
+ allow(File).to receive(:exist?).with('build/path/to/file.webp').and_return(true)
214
+ allow(File).to receive(:read).with('build/path/to/file.webp').and_return('content')
215
+ end
216
+
217
+ it 'falls back to mime-types for known extensions' do
218
+ expect(resource.content_type).to eq 'image/webp'
219
+ end
220
+ end
221
+
222
+ context 'when mm_resource has nil content_type' do
223
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
224
+
225
+ let(:mm_resource) {
226
+ double(
227
+ destination_path: 'path/to/image.svg',
228
+ content_type: nil
229
+ )
230
+ }
231
+
232
+ before do
233
+ allow(File).to receive(:exist?).with('build/path/to/image.svg').and_return(true)
234
+ allow(File).to receive(:read).with('build/path/to/image.svg').and_return('content')
235
+ end
236
+
237
+ it 'falls back to mime-types' do
238
+ expect(resource.content_type).to eq 'image/svg+xml'
239
+ end
240
+ end
241
+
242
+ context 'when content_types option is set' do
243
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
244
+
245
+ let(:mm_resource) {
246
+ double(
247
+ destination_path: 'path/to/data.custom',
248
+ content_type: nil
249
+ )
250
+ }
251
+
252
+ before do
253
+ allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
254
+ allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
255
+ options.content_types = { 'path/to/data.custom' => 'application/x-custom' }
256
+ end
257
+
258
+ it 'uses content_types option by path' do
259
+ expect(resource.content_type).to eq 'application/x-custom'
260
+ end
261
+ end
262
+
263
+ context 'when content_types option uses local_path key' do
264
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
265
+
266
+ let(:mm_resource) {
267
+ double(
268
+ destination_path: 'path/to/data.custom',
269
+ content_type: nil
270
+ )
271
+ }
272
+
273
+ before do
274
+ allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
275
+ allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
276
+ options.content_types = { 'build/path/to/data.custom' => 'application/x-custom-local' }
277
+ end
278
+
279
+ it 'uses content_types option by local_path' do
280
+ expect(resource.content_type).to eq 'application/x-custom-local'
281
+ end
282
+ end
283
+
284
+ context 'when extension is unknown' do
285
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
286
+
287
+ let(:mm_resource) {
288
+ double(
289
+ destination_path: 'path/to/file.unknownext123',
290
+ content_type: nil
291
+ )
292
+ }
293
+
294
+ before do
295
+ allow(File).to receive(:exist?).with('build/path/to/file.unknownext123').and_return(true)
296
+ allow(File).to receive(:read).with('build/path/to/file.unknownext123').and_return('content')
297
+ end
298
+
299
+ it 'defaults to application/octet-stream' do
300
+ expect(resource.content_type).to eq 'application/octet-stream'
301
+ end
302
+ end
303
+ end
304
+
305
+ context 'a resource with explicit path (orphan file)' do
306
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, nil, path: 'orphan/file.webp') }
307
+
308
+ before do
309
+ allow(File).to receive(:exist?).with('build/orphan/file.webp').and_return(true)
310
+ allow(File).to receive(:exist?).with('build/orphan/file.webp.gz').and_return(false)
311
+ allow(File).to receive(:read).with('build/orphan/file.webp').and_return('content')
312
+ allow(File).to receive(:directory?).with('build/orphan/file.webp').and_return(false)
313
+
314
+ # Mock the bucket to return NotFound for head request (file doesn't exist on S3)
315
+ mock_object = instance_double(Aws::S3::Object)
316
+ allow(mock_object).to receive(:head).and_raise(Aws::S3::Errors::NotFound.new(nil, 'Not Found'))
317
+ allow(bucket).to receive(:object).with('orphan/file.webp').and_return(mock_object)
318
+ end
319
+
320
+ its(:path) { is_expected.to eq 'orphan/file.webp' }
321
+ its(:local_path) { is_expected.to eq 'build/orphan/file.webp' }
322
+ its(:status) { is_expected.to eq :new }
323
+
324
+ it 'detects content type via mime-types' do
325
+ expect(resource.content_type).to eq 'image/webp'
326
+ end
327
+ end
328
+
329
+ context 'redirect preservation' do
330
+ context 'remote-only redirect (no local file)' do
331
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
332
+
333
+ let(:remote) do
334
+ double(
335
+ key: 'old-page.html',
336
+ metadata: {},
337
+ etag: '"abc123"',
338
+ content_encoding: nil,
339
+ cache_control: nil,
340
+ website_redirect_location: '/new-page.html'
341
+ )
342
+ end
343
+
344
+ before do
345
+ allow(File).to receive(:exist?).with('build/old-page.html').and_return(false)
346
+ resource.full_s3_resource = remote
347
+ end
348
+
349
+ it 'detects as a redirect' do
350
+ expect(resource.redirect?).to be true
351
+ end
352
+
353
+ its(:status) { is_expected.to eq :ignored }
354
+
355
+ it 'is not marked for deletion' do
356
+ expect(resource.to_delete?).to be false
357
+ end
358
+ end
359
+
360
+ context 'remote file without redirect (no local file)' do
361
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
362
+
363
+ let(:remote) do
364
+ double(
365
+ key: 'deleted-page.html',
366
+ metadata: {},
367
+ etag: '"abc123"',
368
+ content_encoding: nil,
369
+ cache_control: nil,
370
+ website_redirect_location: nil
371
+ )
372
+ end
373
+
374
+ before do
375
+ allow(File).to receive(:exist?).with('build/deleted-page.html').and_return(false)
376
+ resource.full_s3_resource = remote
377
+ end
378
+
379
+ it 'is not a redirect' do
380
+ expect(resource.redirect?).to be_falsey
381
+ end
382
+
383
+ its(:status) { is_expected.to eq :deleted }
384
+
385
+ it 'is marked for deletion' do
386
+ expect(resource.to_delete?).to be true
387
+ end
388
+ end
389
+ end
390
+
185
391
  context 'An ignored resource' do
186
392
  context "that is local" do
187
393