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.
@@ -1,5 +1,6 @@
1
1
  require 'aws-sdk-s3'
2
2
  require 'digest/md5'
3
+ require 'mime/types'
3
4
  require 'middleman/s3_sync/version'
4
5
  require 'middleman/s3_sync/options'
5
6
  require 'middleman/s3_sync/caching_policy'
@@ -11,6 +12,7 @@ require 'middleman/redirect'
11
12
  require 'parallel'
12
13
  require 'ruby-progressbar'
13
14
  require 'thread'
15
+ require 'set'
14
16
 
15
17
  module Middleman
16
18
  module S3Sync
@@ -20,6 +22,7 @@ module Middleman
20
22
 
21
23
  @@bucket_lock = Mutex.new
22
24
  @@bucket_files_lock = Mutex.new
25
+ @@invalidation_paths_lock = Mutex.new
23
26
 
24
27
  attr_accessor :s3_sync_options
25
28
  attr_accessor :mm_resources
@@ -28,11 +31,18 @@ module Middleman
28
31
  THREADS_COUNT = 8
29
32
 
30
33
  # Track paths that were changed during sync for CloudFront invalidation
31
- attr_accessor :invalidation_paths
34
+ # Using a Set for O(1) lookups
35
+ attr_reader :invalidation_paths
32
36
 
33
37
  def sync()
34
38
  @app ||= ::Middleman::Application.new
35
- @invalidation_paths = []
39
+ @invalidation_paths = Set.new
40
+
41
+ # Ensure sitemap is fully populated before syncing
42
+ # This catches resources from extensions activated in :build mode
43
+ if @app.respond_to?(:sitemap) && @app.sitemap.respond_to?(:ensure_resource_list_updated!)
44
+ @app.sitemap.ensure_resource_list_updated!
45
+ end
36
46
 
37
47
  say_status "Let's see if there's work to be done..."
38
48
  unless work_to_be_done?
@@ -58,8 +68,11 @@ module Middleman
58
68
 
59
69
  # Invalidate CloudFront cache if requested
60
70
  if s3_sync_options.cloudfront_invalidate
61
- CloudFront.invalidate(@invalidation_paths, s3_sync_options)
71
+ CloudFront.invalidate(@invalidation_paths.to_a, s3_sync_options)
62
72
  end
73
+
74
+ # Run after_s3_sync callback if provided
75
+ run_after_s3_sync_callback
63
76
  end
64
77
 
65
78
  def bucket
@@ -77,10 +90,12 @@ module Middleman
77
90
  end
78
91
 
79
92
  def add_invalidation_path(path)
80
- @invalidation_paths ||= []
81
- # Normalize path for CloudFront (ensure it starts with /)
82
- normalized_path = path.start_with?('/') ? path : "/#{path}"
83
- @invalidation_paths << normalized_path unless @invalidation_paths.include?(normalized_path)
93
+ @@invalidation_paths_lock.synchronize do
94
+ @invalidation_paths ||= Set.new
95
+ # Normalize path for CloudFront (ensure it starts with /)
96
+ normalized_path = path.start_with?('/') ? path : "/#{path}"
97
+ @invalidation_paths.add(normalized_path)
98
+ end
84
99
  end
85
100
 
86
101
  def remote_only_paths
@@ -95,6 +110,44 @@ module Middleman
95
110
  @content_types || {}
96
111
  end
97
112
 
113
+ # Run the after_s3_sync callback if configured
114
+ def run_after_s3_sync_callback
115
+ callback = s3_sync_options.after_s3_sync
116
+ return unless callback
117
+
118
+ say_status 'callback', 'Running after_s3_sync callback...'
119
+
120
+ # Build sync results hash
121
+ results = {
122
+ created: files_to_create.size,
123
+ updated: files_to_update.size,
124
+ deleted: files_to_delete.size,
125
+ invalidation_paths: @invalidation_paths.to_a
126
+ }
127
+
128
+ begin
129
+ if callback.respond_to?(:call)
130
+ # Lambda/Proc callback - check arity to decide whether to pass results
131
+ if callback.arity == 0 || callback.arity == -1 && callback.parameters.empty?
132
+ callback.call
133
+ else
134
+ callback.call(results)
135
+ end
136
+ elsif callback.is_a?(Symbol) && @app.respond_to?(callback)
137
+ # Symbol method name - call on app
138
+ if @app.method(callback).arity == 0
139
+ @app.send(callback)
140
+ else
141
+ @app.send(callback, results)
142
+ end
143
+ end
144
+
145
+ say_status 'callback', 'after_s3_sync completed successfully'
146
+ rescue => e
147
+ say_status 'error', "after_s3_sync callback failed: #{e.message}"
148
+ end
149
+ end
150
+
98
151
  protected
99
152
  def update_bucket_versioning
100
153
  s3_client.put_bucket_versioning({
@@ -110,10 +163,41 @@ module Middleman
110
163
  opts[:index_document] = { suffix: s3_sync_options.index_document } if s3_sync_options.index_document
111
164
  opts[:error_document] = { key: s3_sync_options.error_document } if s3_sync_options.error_document
112
165
 
166
+ # Add routing rules if specified
167
+ if s3_sync_options.routing_rules && !s3_sync_options.routing_rules.empty?
168
+ opts[:routing_rules] = s3_sync_options.routing_rules.map do |rule|
169
+ routing_rule = {}
170
+
171
+ # Handle condition (optional)
172
+ if rule[:condition] || rule['condition']
173
+ condition = rule[:condition] || rule['condition']
174
+ routing_rule[:condition] = {}
175
+ routing_rule[:condition][:key_prefix_equals] = condition[:key_prefix_equals] || condition['key_prefix_equals'] if condition[:key_prefix_equals] || condition['key_prefix_equals']
176
+ routing_rule[:condition][:http_error_code_returned_equals] = condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals'] if condition[:http_error_code_returned_equals] || condition['http_error_code_returned_equals']
177
+ end
178
+
179
+ # Handle redirect (required)
180
+ redirect = rule[:redirect] || rule['redirect']
181
+ routing_rule[:redirect] = {}
182
+ routing_rule[:redirect][:host_name] = redirect[:host_name] || redirect['host_name'] if redirect[:host_name] || redirect['host_name']
183
+ routing_rule[:redirect][:http_redirect_code] = redirect[:http_redirect_code] || redirect['http_redirect_code'] if redirect[:http_redirect_code] || redirect['http_redirect_code']
184
+ routing_rule[:redirect][:protocol] = redirect[:protocol] || redirect['protocol'] if redirect[:protocol] || redirect['protocol']
185
+ routing_rule[:redirect][:replace_key_prefix_with] = redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with'] if redirect[:replace_key_prefix_with] || redirect['replace_key_prefix_with']
186
+ routing_rule[:redirect][:replace_key_with] = redirect[:replace_key_with] || redirect['replace_key_with'] if redirect[:replace_key_with] || redirect['replace_key_with']
187
+
188
+ routing_rule
189
+ end
190
+ end
191
+
113
192
  if opts[:error_document] && !opts[:index_document]
114
193
  raise 'S3 requires `index_document` if `error_document` is specified'
115
194
  end
116
195
 
196
+ # S3 requires index_document if routing_rules are specified
197
+ if opts[:routing_rules] && !opts[:index_document]
198
+ raise 'S3 requires `index_document` if `routing_rules` are specified'
199
+ end
200
+
117
201
  unless opts.empty?
118
202
  say_status "Putting bucket website: #{opts.to_json}"
119
203
  s3_client.put_bucket_website({
@@ -204,10 +288,22 @@ module Middleman
204
288
  end
205
289
 
206
290
  def delete_resources
207
- Parallel.map(files_to_delete, in_threads: THREADS_COUNT) do |resource|
208
- resource.destroy!
291
+ resources = files_to_delete
292
+ return if resources.empty?
293
+
294
+ # Print status messages for all resources being deleted
295
+ resources.each do |resource|
296
+ say_status "#{ANSI.red{"Deleting"}} #{resource.remote_path}"
209
297
  add_invalidation_path(resource.path)
210
298
  end
299
+
300
+ # Batch delete using S3's delete_objects API (up to 1000 objects per request)
301
+ unless s3_sync_options.dry_run
302
+ resources.each_slice(1000) do |batch|
303
+ objects_to_delete = batch.map { |r| { key: r.remote_path.sub(/^\//, '') } }
304
+ bucket.delete_objects(delete: { objects: objects_to_delete })
305
+ end
306
+ end
211
307
  end
212
308
 
213
309
  def ignore_resources
@@ -217,6 +313,11 @@ module Middleman
217
313
  def work_to_be_done?
218
314
  Parallel.each(mm_resources, in_threads: THREADS_COUNT, progress: "Processing sitemap") { |mm_resource| add_local_resource(mm_resource) }
219
315
 
316
+ # Scan build directory for orphan files (not in sitemap)
317
+ if s3_sync_options.scan_build_dir
318
+ discover_orphan_files
319
+ end
320
+
220
321
  Parallel.each(remote_only_paths, in_threads: THREADS_COUNT, progress: "Processing remote files") do |remote_path|
221
322
  s3_sync_resources[remote_path] ||= S3Sync::Resource.new(nil, remote_resource_for_path(remote_path)).tap(&:status)
222
323
  end
@@ -224,24 +325,73 @@ module Middleman
224
325
  !(files_to_create.empty? && files_to_update.empty? && files_to_delete.empty?)
225
326
  end
226
327
 
328
+ # Discover files in build directory that are not in the sitemap
329
+ # This handles files generated by after_build callbacks, image optimizers, etc.
330
+ def discover_orphan_files
331
+ return unless build_dir && File.directory?(build_dir)
332
+
333
+ orphan_files = []
334
+
335
+ Dir.glob(File.join(build_dir, '**', '*')).each do |file_path|
336
+ next if File.directory?(file_path)
337
+
338
+ # Get path relative to build_dir
339
+ relative_path = file_path.sub(/^#{Regexp.escape(build_dir)}\/?/, '')
340
+
341
+ # Skip if already in sitemap resources
342
+ next if s3_sync_resources.key?(relative_path)
343
+
344
+ orphan_files << relative_path
345
+ end
346
+
347
+ return if orphan_files.empty?
348
+
349
+ say_status "Found #{orphan_files.size} files outside sitemap"
350
+
351
+ Parallel.each(orphan_files, in_threads: THREADS_COUNT, progress: "Processing orphan files") do |relative_path|
352
+ # Create a Resource with nil mm_resource but explicit path
353
+ # It will use mime-types for content type detection
354
+ remote = remote_resource_for_path(relative_path)
355
+ resource = S3Sync::Resource.new(nil, remote, path: relative_path)
356
+ s3_sync_resources[relative_path] = resource.tap(&:status)
357
+ end
358
+ end
359
+
360
+ # Single-pass categorization of resources by status
361
+ # Avoids multiple iterations over s3_sync_resources
362
+ def categorized_resources
363
+ @categorized_resources ||= begin
364
+ result = { create: [], update: [], delete: [], ignore: [] }
365
+ s3_sync_resources.values.each do |resource|
366
+ case
367
+ when resource.to_create? then result[:create] << resource
368
+ when resource.to_update? then result[:update] << resource
369
+ when resource.to_delete? then result[:delete] << resource
370
+ when resource.to_ignore? then result[:ignore] << resource
371
+ end
372
+ end
373
+ result
374
+ end
375
+ end
376
+
227
377
  def files_to_delete
228
378
  if s3_sync_options.delete
229
- s3_sync_resources.values.select { |r| r.to_delete? }
379
+ categorized_resources[:delete]
230
380
  else
231
381
  []
232
382
  end
233
383
  end
234
384
 
235
385
  def files_to_create
236
- s3_sync_resources.values.select { |r| r.to_create? }
386
+ categorized_resources[:create]
237
387
  end
238
388
 
239
389
  def files_to_update
240
- s3_sync_resources.values.select { |r| r.to_update? }
390
+ categorized_resources[:update]
241
391
  end
242
392
 
243
393
  def files_to_ignore
244
- s3_sync_resources.values.select { |r| r.to_ignore? }
394
+ categorized_resources[:ignore]
245
395
  end
246
396
 
247
397
  def build_dir
@@ -102,7 +102,9 @@ module Middleman
102
102
  verbose = options[:verbose] ? 0 : 1
103
103
  instrument = options[:instrument]
104
104
 
105
- mode = options[:build] ? :build : :config
105
+ # Always use :build mode to ensure build-time extensions are active
106
+ # (e.g., asset_hash in `configure :build` blocks)
107
+ mode = :build
106
108
 
107
109
  ::Middleman::S3Sync.app = ::Middleman::Application.new do
108
110
  config[:mode] = mode
@@ -26,8 +26,10 @@ module Middleman
26
26
  option :dry_run, false, 'Whether to perform a dry-run'
27
27
  option :index_document, nil, 'S3 custom index document path'
28
28
  option :error_document, nil, 'S3 custom error document path'
29
+ option :routing_rules, [], 'S3 website routing rules (array of rule hashes)'
29
30
  option :content_types, {}, 'Custom content types'
30
31
  option :ignore_paths, [], 'Paths that should be ignored during sync, strings or regex are allowed'
32
+ option :scan_build_dir, false, 'Scan build directory for files not in sitemap (e.g., after_build files)'
31
33
  option :cloudfront_distribution_id, nil, 'CloudFront distribution ID for invalidation'
32
34
  option :cloudfront_invalidate, false, 'Whether to invalidate CloudFront cache after sync'
33
35
  option :cloudfront_invalidate_all, false, 'Whether to invalidate all paths (/*) or only changed files'
@@ -35,6 +37,7 @@ module Middleman
35
37
  option :cloudfront_invalidation_max_retries, 5, 'Maximum number of retries for rate-limited invalidation requests'
36
38
  option :cloudfront_invalidation_batch_delay, 2, 'Delay in seconds between invalidation batches'
37
39
  option :cloudfront_wait, false, 'Whether to wait for CloudFront invalidation to complete'
40
+ option :after_s3_sync, nil, 'Proc/lambda to call after sync completes (receives changed_paths array)'
38
41
 
39
42
  expose_to_config :s3_sync_options, :default_caching_policy, :caching_policy
40
43
 
@@ -87,16 +90,30 @@ module Middleman
87
90
  true
88
91
  end
89
92
 
90
- # Delegate option readers to the options object
93
+ # Delegate option readers and writers to the options object
91
94
  def method_missing(method, *args, &block)
92
- if options.respond_to?(method)
93
- options.send(method, *args, &block)
94
- else
95
- super
95
+ method_str = method.to_s
96
+
97
+ # Handle setter methods (e.g., verbose=)
98
+ if method_str.end_with?('=')
99
+ option_name = method_str.chomp('=').to_sym
100
+ if options.respond_to?(option_name)
101
+ options[option_name] = args.first
102
+ return args.first
103
+ end
104
+ elsif options.respond_to?(method)
105
+ return options.send(method, *args, &block)
96
106
  end
107
+
108
+ super
97
109
  end
98
110
 
99
111
  def respond_to_missing?(method, include_private = false)
112
+ method_str = method.to_s
113
+ if method_str.end_with?('=')
114
+ option_name = method_str.chomp('=').to_sym
115
+ return options.respond_to?(option_name)
116
+ end
100
117
  options.respond_to?(method) || super
101
118
  end
102
119
 
@@ -13,28 +13,32 @@ Gem::Specification.new do |gem|
13
13
  gem.homepage = "http://github.com/fredjean/middleman-s3_sync"
14
14
  gem.license = 'MIT'
15
15
 
16
+ gem.required_ruby_version = '>= 3.0'
17
+
16
18
  gem.files = `git ls-files`.split($/)
17
19
  gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
18
20
  gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
19
21
  gem.require_paths = ["lib"]
20
22
 
21
- gem.add_runtime_dependency 'middleman-core'
22
- gem.add_runtime_dependency 'middleman-cli'
23
- gem.add_runtime_dependency 'aws-sdk-s3', '>= 1.187.0'
24
- gem.add_runtime_dependency 'aws-sdk-cloudfront'
25
- gem.add_runtime_dependency 'parallel'
26
- gem.add_runtime_dependency 'ruby-progressbar'
27
- gem.add_runtime_dependency 'ansi', '~> 1.5.0'
28
- gem.add_runtime_dependency 'mime-types', '~> 3.1'
29
- gem.add_runtime_dependency 'nokogiri', '>= 1.18.4'
23
+ # Runtime dependencies
24
+ gem.add_runtime_dependency 'middleman-core', '~> 4.4'
25
+ gem.add_runtime_dependency 'middleman-cli', '~> 4.4'
26
+ gem.add_runtime_dependency 'aws-sdk-s3', '~> 1.187', '>= 1.187.0'
27
+ gem.add_runtime_dependency 'aws-sdk-cloudfront', '~> 1.0'
28
+ gem.add_runtime_dependency 'parallel', '~> 1.20'
29
+ gem.add_runtime_dependency 'ruby-progressbar', '~> 1.11'
30
+ gem.add_runtime_dependency 'ansi', '~> 1.5'
31
+ gem.add_runtime_dependency 'mime-types', '~> 3.4'
32
+ gem.add_runtime_dependency 'nokogiri', '~> 1.18', '>= 1.18.4'
30
33
 
31
- gem.add_development_dependency 'rake'
32
- gem.add_development_dependency 'pry'
33
- gem.add_development_dependency 'pry-byebug'
34
- gem.add_development_dependency 'rspec'
35
- gem.add_development_dependency 'rspec-support'
36
- gem.add_development_dependency 'rspec-its'
37
- gem.add_development_dependency 'rspec-mocks'
38
- gem.add_development_dependency 'timerizer'
39
- gem.add_development_dependency 'webrick'
34
+ # Development dependencies
35
+ gem.add_development_dependency 'rake', '~> 13.0'
36
+ gem.add_development_dependency 'pry', '~> 0.14'
37
+ gem.add_development_dependency 'pry-byebug', '~> 3.10'
38
+ gem.add_development_dependency 'rspec', '~> 3.12'
39
+ gem.add_development_dependency 'rspec-support', '~> 3.12'
40
+ gem.add_development_dependency 'rspec-its', '~> 2.0'
41
+ gem.add_development_dependency 'rspec-mocks', '~> 3.12'
42
+ gem.add_development_dependency 'timerizer', '~> 0.3'
43
+ gem.add_development_dependency 'webrick', '~> 1.8'
40
44
  end
@@ -87,6 +87,60 @@ describe 'AWS SDK Parameter Validation' do
87
87
  }.to raise_error('S3 requires `index_document` if `error_document` is specified')
88
88
  end
89
89
  end
90
+
91
+ context 'when routing_rules are set' do
92
+ before do
93
+ options.routing_rules = [
94
+ {
95
+ condition: { key_prefix_equals: 'docs/' },
96
+ redirect: { replace_key_prefix_with: 'documents/' }
97
+ },
98
+ {
99
+ condition: { http_error_code_returned_equals: '404' },
100
+ redirect: { host_name: 'example.com', replace_key_with: 'error.html' }
101
+ }
102
+ ]
103
+ end
104
+
105
+ it 'includes routing_rules in website configuration' do
106
+ expect(s3_client).to receive(:put_bucket_website) do |params|
107
+ expect(params[:website_configuration]).to have_key(:routing_rules)
108
+ rules = params[:website_configuration][:routing_rules]
109
+ expect(rules).to be_an(Array)
110
+ expect(rules.length).to eq(2)
111
+
112
+ # First rule
113
+ expect(rules[0][:condition][:key_prefix_equals]).to eq('docs/')
114
+ expect(rules[0][:redirect][:replace_key_prefix_with]).to eq('documents/')
115
+
116
+ # Second rule
117
+ expect(rules[1][:condition][:http_error_code_returned_equals]).to eq('404')
118
+ expect(rules[1][:redirect][:host_name]).to eq('example.com')
119
+ expect(rules[1][:redirect][:replace_key_with]).to eq('error.html')
120
+ end
121
+
122
+ Middleman::S3Sync.send(:update_bucket_website)
123
+ end
124
+ end
125
+
126
+ context 'when routing_rules are set without index_document' do
127
+ before do
128
+ options.index_document = nil
129
+ options.error_document = nil
130
+ options.routing_rules = [
131
+ {
132
+ condition: { key_prefix_equals: 'old/' },
133
+ redirect: { replace_key_prefix_with: 'new/' }
134
+ }
135
+ ]
136
+ end
137
+
138
+ it 'raises an error because S3 requires index_document if routing_rules are specified' do
139
+ expect {
140
+ Middleman::S3Sync.send(:update_bucket_website)
141
+ }.to raise_error('S3 requires `index_document` if `routing_rules` are specified')
142
+ end
143
+ end
90
144
  end
91
145
 
92
146
  describe 'put_bucket_versioning parameters' do
@@ -128,14 +182,17 @@ describe 'AWS SDK Parameter Validation' do
128
182
  allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
129
183
  allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
130
184
  allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
185
+ # Stub File.open to return a StringIO for streaming upload tests
186
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
131
187
  allow(s3_object).to receive(:head).and_return(nil)
132
188
  options.dry_run = false
133
189
  end
134
190
 
135
191
  it 'uses correct metadata key format' do
136
192
  expect(s3_object).to receive(:put) do |upload_options|
137
- # Verify basic parameters
138
- expect(upload_options[:body]).to eq('test content')
193
+ # Verify body is a readable IO object (for streaming)
194
+ expect(upload_options[:body]).to respond_to(:read)
195
+ expect(upload_options[:body].read).to eq('test content')
139
196
  expect(upload_options[:content_type]).to eq('text/html')
140
197
  expect(upload_options[:acl]).to eq('public-read')
141
198
 
@@ -160,7 +217,7 @@ describe 'AWS SDK Parameter Validation' do
160
217
  it 'does not include acl parameter in upload' do
161
218
  expect(s3_object).to receive(:put) do |upload_options|
162
219
  expect(upload_options).not_to have_key(:acl)
163
- expect(upload_options[:body]).to eq('test content')
220
+ expect(upload_options[:body]).to respond_to(:read)
164
221
  expect(upload_options[:content_type]).to eq('text/html')
165
222
  end
166
223
 
@@ -176,7 +233,7 @@ describe 'AWS SDK Parameter Validation' do
176
233
  it 'does not include acl parameter in upload' do
177
234
  expect(s3_object).to receive(:put) do |upload_options|
178
235
  expect(upload_options).not_to have_key(:acl)
179
- expect(upload_options[:body]).to eq('test content')
236
+ expect(upload_options[:body]).to respond_to(:read)
180
237
  expect(upload_options[:content_type]).to eq('text/html')
181
238
  end
182
239
 
@@ -192,6 +249,10 @@ describe 'AWS SDK Parameter Validation' do
192
249
 
193
250
  it 'automatically retries without ACL when AccessControlListNotSupported error occurs' do
194
251
  call_count = 0
252
+ # Use a reusable StringIO that can be rewound
253
+ file_io = StringIO.new('test content')
254
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(file_io)
255
+
195
256
  expect(s3_object).to receive(:put).twice do |upload_options|
196
257
  call_count += 1
197
258
  if call_count == 1
@@ -201,7 +262,7 @@ describe 'AWS SDK Parameter Validation' do
201
262
  else
202
263
  # Second call should not include ACL
203
264
  expect(upload_options).not_to have_key(:acl)
204
- expect(upload_options[:body]).to eq('test content')
265
+ expect(upload_options[:body]).to respond_to(:read)
205
266
  expect(upload_options[:content_type]).to eq('text/html')
206
267
  end
207
268
  end
@@ -219,7 +280,7 @@ describe 'AWS SDK Parameter Validation' do
219
280
  expect(upload_options[:acl]).to eq('public-read')
220
281
  raise Aws::S3::Errors::AccessControlListNotSupported.new(nil, 'The bucket does not allow ACLs')
221
282
  else
222
- # Second call should succeed without ACL
283
+ # Subsequent calls should succeed without ACL
223
284
  expect(upload_options).not_to have_key(:acl)
224
285
  true
225
286
  end
@@ -241,6 +302,7 @@ describe 'AWS SDK Parameter Validation' do
241
302
  allow(File).to receive(:read).with('build/test/file.html.gz').and_return('gzipped content')
242
303
  allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
243
304
  allow(File).to receive(:read).with('build/test/file.html').and_return('original content')
305
+ allow(File).to receive(:open).with('build/test/file.html.gz', 'rb').and_yield(StringIO.new('gzipped content'))
244
306
 
245
307
  # Mock the HEAD response to avoid calling it during redirect?
246
308
  head_response = double(
@@ -305,6 +367,7 @@ describe 'AWS SDK Parameter Validation' do
305
367
  allow(File).to receive(:exist?).with('build/redirect/file.html').and_return(true)
306
368
  allow(File).to receive(:exist?).with('build/redirect/file.html.gz').and_return(false)
307
369
  allow(File).to receive(:read).with('build/redirect/file.html').and_return('redirect content')
370
+ allow(File).to receive(:open).with('build/redirect/file.html', 'rb').and_yield(StringIO.new('redirect content'))
308
371
  allow(File).to receive(:directory?).with('build/redirect/file.html').and_return(false)
309
372
  allow(s3_object).to receive(:head).and_return(nil)
310
373
  allow(resource).to receive(:redirect?).and_return(true)
@@ -478,6 +541,7 @@ describe 'AWS SDK Parameter Validation' do
478
541
  allow(File).to receive(:exist?).with('build/test/file.html').and_return(true)
479
542
  allow(File).to receive(:exist?).with('build/test/file.html.gz').and_return(false)
480
543
  allow(File).to receive(:read).with('build/test/file.html').and_return('test content')
544
+ allow(File).to receive(:open).with('build/test/file.html', 'rb').and_yield(StringIO.new('test content'))
481
545
  allow(File).to receive(:directory?).with('build/test/file.html').and_return(false)
482
546
  allow(s3_object).to receive(:head).and_return(nil)
483
547
  options.dry_run = false
@@ -27,6 +27,8 @@ describe Middleman::S3Sync::CloudFront do
27
27
 
28
28
  before do
29
29
  allow(described_class).to receive(:say_status)
30
+ # Reset cached CloudFront client between tests to prevent double leakage
31
+ described_class.send(:reset_cloudfront_client!)
30
32
  end
31
33
 
32
34
  describe '.invalidate' do