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.
@@ -15,11 +15,21 @@ describe 'S3Sync CloudFront Integration' do
15
15
  dry_run: false,
16
16
  verbose: false,
17
17
  delete: true,
18
- bucket: 'test-bucket'
18
+ bucket: 'test-bucket',
19
+ after_s3_sync: nil
19
20
  )
20
21
  end
21
22
 
22
23
  before do
24
+ # Reset cached app to avoid double leakage between tests
25
+ Middleman::S3Sync.instance_variable_set(:@app, nil)
26
+
27
+ # Mock sitemap for ensure_resource_list_updated! call
28
+ sitemap = double('sitemap')
29
+ allow(sitemap).to receive(:ensure_resource_list_updated!)
30
+ allow(app).to receive(:respond_to?).with(:sitemap).and_return(true)
31
+ allow(app).to receive(:sitemap).and_return(sitemap)
32
+
23
33
  allow(::Middleman::Application).to receive(:new).and_return(app)
24
34
  allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
25
35
  allow(Middleman::S3Sync).to receive(:say_status)
@@ -36,7 +46,7 @@ describe 'S3Sync CloudFront Integration' do
36
46
  describe 'CloudFront invalidation integration' do
37
47
  it 'calls CloudFront invalidation after sync operations' do
38
48
  # Reset invalidation paths for this test
39
- Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
49
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
40
50
 
41
51
  expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
42
52
  [], # Initially empty, gets populated during resource operations
@@ -70,7 +80,8 @@ describe 'S3Sync CloudFront Integration' do
70
80
  cloudfront_invalidate: true,
71
81
  cloudfront_distribution_id: 'E1234567890123',
72
82
  cloudfront_invalidate_all: true,
73
- bucket: 'test-bucket'
83
+ bucket: 'test-bucket',
84
+ after_s3_sync: nil
74
85
  )
75
86
  end
76
87
 
@@ -89,7 +100,8 @@ describe 'S3Sync CloudFront Integration' do
89
100
  let(:s3_sync_options) do
90
101
  double(
91
102
  cloudfront_invalidate: false,
92
- bucket: 'test-bucket'
103
+ bucket: 'test-bucket',
104
+ after_s3_sync: nil
93
105
  )
94
106
  end
95
107
 
@@ -101,10 +113,32 @@ describe 'S3Sync CloudFront Integration' do
101
113
  end
102
114
  end
103
115
 
116
+ describe 'sitemap population' do
117
+ it 'calls ensure_resource_list_updated! before processing resources' do
118
+ sitemap = double('sitemap')
119
+ expect(sitemap).to receive(:ensure_resource_list_updated!)
120
+ allow(sitemap).to receive(:respond_to?).with(:ensure_resource_list_updated!).and_return(true)
121
+ allow(app).to receive(:sitemap).and_return(sitemap)
122
+
123
+ Middleman::S3Sync.sync
124
+ end
125
+
126
+ it 'handles apps without sitemap gracefully' do
127
+ Middleman::S3Sync.instance_variable_set(:@app, nil)
128
+
129
+ app_without_sitemap = double('app_without_sitemap')
130
+ allow(app_without_sitemap).to receive(:respond_to?).with(:sitemap).and_return(false)
131
+ allow(::Middleman::Application).to receive(:new).and_return(app_without_sitemap)
132
+
133
+ # Should not raise an error
134
+ expect { Middleman::S3Sync.sync }.not_to raise_error
135
+ end
136
+ end
137
+
104
138
  describe 'path tracking during resource operations' do
105
139
  before do
106
140
  # Reset invalidation paths before each path tracking test
107
- Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
141
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
108
142
  end
109
143
 
110
144
  it 'adds paths to invalidation list when resources are processed' do
@@ -126,7 +160,328 @@ describe 'S3Sync CloudFront Integration' do
126
160
  Middleman::S3Sync.add_invalidation_path('/same/path.html')
127
161
  Middleman::S3Sync.add_invalidation_path('/same/path.html')
128
162
 
129
- expect(Middleman::S3Sync.invalidation_paths.count('/same/path.html')).to eq(1)
163
+ # Set automatically handles uniqueness - verify it contains exactly one occurrence
164
+ expect(Middleman::S3Sync.invalidation_paths.to_a.count('/same/path.html')).to eq(1)
165
+ end
166
+ end
167
+
168
+ describe 'orphan file discovery (scan_build_dir)' do
169
+ let(:build_dir) { Dir.mktmpdir }
170
+
171
+ after do
172
+ FileUtils.remove_entry(build_dir) if File.directory?(build_dir)
173
+ end
174
+
175
+ before do
176
+ # Reset state
177
+ Middleman::S3Sync.instance_variable_set(:@s3_sync_resources, {})
178
+ Middleman::S3Sync.instance_variable_set(:@bucket_files, {})
179
+
180
+ allow(Middleman::S3Sync).to receive(:say_status)
181
+ allow(Middleman::S3Sync).to receive(:build_dir).and_return(build_dir)
182
+ allow(Middleman::S3Sync).to receive(:remote_resource_for_path).and_return(nil)
183
+ end
184
+
185
+ context 'when scan_build_dir is enabled' do
186
+ let(:s3_sync_options) do
187
+ double(
188
+ scan_build_dir: true,
189
+ build_dir: build_dir,
190
+ bucket: 'test-bucket',
191
+ prefix: nil,
192
+ delete: false,
193
+ verbose: false,
194
+ ignore_paths: [],
195
+ prefer_gzip: false,
196
+ force: false,
197
+ acl: 'public-read',
198
+ after_s3_sync: nil
199
+ )
200
+ end
201
+
202
+ it 'discovers files not in sitemap' do
203
+ # Create orphan files in build directory
204
+ FileUtils.mkdir_p(File.join(build_dir, 'images'))
205
+ File.write(File.join(build_dir, 'orphan.txt'), 'orphan content')
206
+ File.write(File.join(build_dir, 'images', 'optimized.webp'), 'image data')
207
+
208
+ # Mock Resource creation to avoid S3 calls
209
+ allow(Middleman::S3Sync::Resource).to receive(:new) do |resource, remote, path: nil|
210
+ mock_resource = double('resource', status: :new, path: path)
211
+ allow(mock_resource).to receive(:tap).and_yield(mock_resource).and_return(mock_resource)
212
+ mock_resource
213
+ end
214
+
215
+ Middleman::S3Sync.send(:discover_orphan_files)
216
+
217
+ resources = Middleman::S3Sync.send(:s3_sync_resources)
218
+ expect(resources.keys).to include('orphan.txt')
219
+ expect(resources.keys).to include('images/optimized.webp')
220
+ end
221
+
222
+ it 'skips files already in sitemap' do
223
+ File.write(File.join(build_dir, 'existing.html'), 'content')
224
+
225
+ # Pre-populate sitemap resource
226
+ Middleman::S3Sync.send(:s3_sync_resources)['existing.html'] = double('resource')
227
+
228
+ # Count how many times Resource.new is called
229
+ call_count = 0
230
+ allow(Middleman::S3Sync::Resource).to receive(:new) do |resource, remote, path: nil|
231
+ call_count += 1
232
+ mock_resource = double('resource', status: :new, path: path)
233
+ allow(mock_resource).to receive(:tap).and_yield(mock_resource).and_return(mock_resource)
234
+ mock_resource
235
+ end
236
+
237
+ Middleman::S3Sync.send(:discover_orphan_files)
238
+
239
+ # Should not have created a new resource for existing.html
240
+ expect(call_count).to eq(0)
241
+ end
242
+
243
+ it 'skips directories' do
244
+ FileUtils.mkdir_p(File.join(build_dir, 'subdir', 'nested'))
245
+ File.write(File.join(build_dir, 'subdir', 'file.txt'), 'content')
246
+
247
+ created_paths = []
248
+ allow(Middleman::S3Sync::Resource).to receive(:new) do |resource, remote, path: nil|
249
+ created_paths << path
250
+ mock_resource = double('resource', status: :new, path: path)
251
+ allow(mock_resource).to receive(:tap).and_yield(mock_resource).and_return(mock_resource)
252
+ mock_resource
253
+ end
254
+
255
+ Middleman::S3Sync.send(:discover_orphan_files)
256
+
257
+ expect(created_paths).to include('subdir/file.txt')
258
+ expect(created_paths).not_to include('subdir')
259
+ expect(created_paths).not_to include('subdir/nested')
260
+ end
261
+ end
262
+
263
+ context 'when scan_build_dir is disabled' do
264
+ let(:s3_sync_options) do
265
+ double(
266
+ scan_build_dir: false,
267
+ build_dir: build_dir,
268
+ bucket: 'test-bucket',
269
+ after_s3_sync: nil
270
+ )
271
+ end
272
+
273
+ it 'does not scan for orphan files' do
274
+ File.write(File.join(build_dir, 'orphan.txt'), 'content')
275
+
276
+ expect(Middleman::S3Sync).not_to receive(:discover_orphan_files)
277
+
278
+ # Call work_to_be_done? but mock the heavy parts
279
+ allow(Middleman::S3Sync).to receive(:mm_resources).and_return([])
280
+ allow(Middleman::S3Sync).to receive(:remote_only_paths).and_return([])
281
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return([])
282
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return([])
283
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
284
+
285
+ Middleman::S3Sync.send(:work_to_be_done?)
286
+ end
287
+ end
288
+ end
289
+
290
+ describe 'batch delete operations' do
291
+ let(:bucket) { double('bucket') }
292
+ let(:resource1) { double('resource1', path: 'file1.html', remote_path: 'file1.html') }
293
+ let(:resource2) { double('resource2', path: 'file2.html', remote_path: 'file2.html') }
294
+ let(:resource3) { double('resource3', path: 'file3.html', remote_path: 'file3.html') }
295
+
296
+ before do
297
+ # Remove the stub for delete_resources so we test the actual implementation
298
+ allow(Middleman::S3Sync).to receive(:delete_resources).and_call_original
299
+ allow(Middleman::S3Sync).to receive(:say_status)
300
+ allow(Middleman::S3Sync).to receive(:bucket).and_return(bucket)
301
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
302
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, Set.new)
303
+ Middleman::S3Sync.instance_variable_set(:@categorized_resources, nil)
304
+ end
305
+
306
+ it 'uses batch delete_objects API instead of individual deletes' do
307
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2, resource3])
308
+
309
+ expect(bucket).to receive(:delete_objects).with(
310
+ delete: {
311
+ objects: [
312
+ { key: 'file1.html' },
313
+ { key: 'file2.html' },
314
+ { key: 'file3.html' }
315
+ ]
316
+ }
317
+ )
318
+
319
+ Middleman::S3Sync.send(:delete_resources)
320
+ end
321
+
322
+ it 'adds invalidation paths for all deleted resources' do
323
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1, resource2])
324
+ allow(bucket).to receive(:delete_objects)
325
+
326
+ Middleman::S3Sync.send(:delete_resources)
327
+
328
+ expect(Middleman::S3Sync.invalidation_paths).to include('/file1.html')
329
+ expect(Middleman::S3Sync.invalidation_paths).to include('/file2.html')
330
+ end
331
+
332
+ it 'does not call delete_objects during dry run' do
333
+ dry_run_options = double(
334
+ dry_run: true,
335
+ delete: true,
336
+ bucket: 'test-bucket',
337
+ after_s3_sync: nil
338
+ )
339
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(dry_run_options)
340
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([resource1])
341
+
342
+ expect(bucket).not_to receive(:delete_objects)
343
+
344
+ Middleman::S3Sync.send(:delete_resources)
345
+ end
346
+
347
+ it 'does nothing when there are no files to delete' do
348
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
349
+
350
+ expect(bucket).not_to receive(:delete_objects)
351
+
352
+ Middleman::S3Sync.send(:delete_resources)
353
+ end
354
+ end
355
+
356
+ describe 'after_s3_sync callback' do
357
+ before do
358
+ Middleman::S3Sync.instance_variable_set(:@app, nil)
359
+
360
+ sitemap = double('sitemap')
361
+ allow(sitemap).to receive(:ensure_resource_list_updated!)
362
+ allow(app).to receive(:respond_to?).with(:sitemap).and_return(true)
363
+ allow(app).to receive(:sitemap).and_return(sitemap)
364
+
365
+ allow(::Middleman::Application).to receive(:new).and_return(app)
366
+ allow(Middleman::S3Sync).to receive(:say_status)
367
+ allow(Middleman::S3Sync).to receive(:work_to_be_done?).and_return(true)
368
+ allow(Middleman::S3Sync).to receive(:update_bucket_versioning)
369
+ allow(Middleman::S3Sync).to receive(:update_bucket_website)
370
+ allow(Middleman::S3Sync).to receive(:ignore_resources)
371
+ allow(Middleman::S3Sync).to receive(:create_resources)
372
+ allow(Middleman::S3Sync).to receive(:update_resources)
373
+ allow(Middleman::S3Sync).to receive(:delete_resources)
374
+ allow(Middleman::S3Sync::CloudFront).to receive(:invalidate)
375
+ end
376
+
377
+ context 'when after_s3_sync callback is provided' do
378
+ it 'executes the callback after sync completes' do
379
+ callback_executed = false
380
+ callback_options = double(
381
+ cloudfront_invalidate: false,
382
+ bucket: 'test-bucket',
383
+ after_s3_sync: -> { callback_executed = true }
384
+ )
385
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(callback_options)
386
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return([])
387
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return([])
388
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
389
+
390
+ Middleman::S3Sync.sync
391
+
392
+ expect(callback_executed).to be true
393
+ end
394
+
395
+ it 'passes sync results to the callback' do
396
+ received_results = nil
397
+ callback_options = double(
398
+ cloudfront_invalidate: false,
399
+ bucket: 'test-bucket',
400
+ after_s3_sync: ->(results) { received_results = results }
401
+ )
402
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(callback_options)
403
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return(['file1.html'])
404
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return(['file2.html', 'file3.html'])
405
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
406
+
407
+ Middleman::S3Sync.sync
408
+
409
+ expect(received_results).to be_a(Hash)
410
+ expect(received_results[:created]).to eq(1)
411
+ expect(received_results[:updated]).to eq(2)
412
+ expect(received_results[:deleted]).to eq(0)
413
+ end
414
+
415
+ it 'logs callback execution status' do
416
+ callback_options = double(
417
+ cloudfront_invalidate: false,
418
+ bucket: 'test-bucket',
419
+ after_s3_sync: -> { 'done' }
420
+ )
421
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(callback_options)
422
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return([])
423
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return([])
424
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
425
+
426
+ expect(Middleman::S3Sync).to receive(:say_status).with('callback', /Running after_s3_sync/)
427
+ expect(Middleman::S3Sync).to receive(:say_status).with('callback', /after_s3_sync completed/)
428
+
429
+ Middleman::S3Sync.sync
430
+ end
431
+
432
+ it 'handles callback errors gracefully' do
433
+ error_callback_options = double(
434
+ cloudfront_invalidate: false,
435
+ bucket: 'test-bucket',
436
+ after_s3_sync: ->(_results) { raise 'Callback error!' }
437
+ )
438
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(error_callback_options)
439
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return([])
440
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return([])
441
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
442
+
443
+ expect(Middleman::S3Sync).to receive(:say_status).with('error', /Callback error!/)
444
+ expect { Middleman::S3Sync.sync }.not_to raise_error
445
+ end
446
+ end
447
+
448
+ context 'when after_s3_sync callback is nil' do
449
+ it 'does not attempt to execute a callback' do
450
+ nil_callback_options = double(
451
+ cloudfront_invalidate: false,
452
+ bucket: 'test-bucket',
453
+ after_s3_sync: nil
454
+ )
455
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(nil_callback_options)
456
+
457
+ # Should not log callback execution
458
+ expect(Middleman::S3Sync).not_to receive(:say_status).with('callback', anything)
459
+
460
+ Middleman::S3Sync.sync
461
+ end
462
+ end
463
+
464
+ context 'when after_s3_sync callback is a method name' do
465
+ it 'executes the method on the app' do
466
+ method_executed = false
467
+ allow(app).to receive(:my_callback) { method_executed = true }
468
+ allow(app).to receive(:respond_to?).with(:my_callback).and_return(true)
469
+ allow(app).to receive(:method).with(:my_callback).and_return(double(arity: 0))
470
+
471
+ method_callback_options = double(
472
+ cloudfront_invalidate: false,
473
+ bucket: 'test-bucket',
474
+ after_s3_sync: :my_callback
475
+ )
476
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(method_callback_options)
477
+ allow(Middleman::S3Sync).to receive(:files_to_create).and_return([])
478
+ allow(Middleman::S3Sync).to receive(:files_to_update).and_return([])
479
+ allow(Middleman::S3Sync).to receive(:files_to_delete).and_return([])
480
+
481
+ Middleman::S3Sync.sync
482
+
483
+ expect(method_executed).to be true
484
+ end
130
485
  end
131
486
  end
132
- end
487
+ end