middleman-s3_sync 4.0.3 → 4.6.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,391 @@
1
+ require 'spec_helper'
2
+ require 'middleman/s3_sync/cloudfront'
3
+
4
+ describe Middleman::S3Sync::CloudFront do
5
+ let(:options) do
6
+ double(
7
+ cloudfront_invalidate: true,
8
+ cloudfront_distribution_id: 'E1234567890123',
9
+ cloudfront_invalidate_all: false,
10
+ cloudfront_invalidation_batch_size: 1000,
11
+ cloudfront_wait: false,
12
+ aws_access_key_id: 'test_key',
13
+ aws_secret_access_key: 'test_secret',
14
+ aws_session_token: nil,
15
+ dry_run: false,
16
+ verbose: false
17
+ )
18
+ end
19
+
20
+ let(:invalidation_response) do
21
+ double(
22
+ invalidation: double(id: 'I1234567890123')
23
+ )
24
+ end
25
+
26
+ before do
27
+ allow(described_class).to receive(:say_status)
28
+ end
29
+
30
+ describe '.invalidate' do
31
+ context 'when CloudFront invalidation is disabled' do
32
+ let(:options) do
33
+ double(cloudfront_invalidate: false)
34
+ end
35
+
36
+ it 'returns early without doing anything' do
37
+ expect(Aws::CloudFront::Client).not_to receive(:new)
38
+ result = described_class.invalidate(['/path1', '/path2'], options)
39
+ expect(result).to be_nil
40
+ end
41
+ end
42
+
43
+ context 'when no distribution ID is provided' do
44
+ let(:options) do
45
+ double(
46
+ cloudfront_invalidate: true,
47
+ cloudfront_distribution_id: nil
48
+ )
49
+ end
50
+
51
+ it 'skips invalidation and shows warning' do
52
+ expect(described_class).to receive(:say_status).with(
53
+ match(/CloudFront invalidation skipped.*no distribution ID/)
54
+ )
55
+ expect(Aws::CloudFront::Client).not_to receive(:new)
56
+ result = described_class.invalidate(['/path1', '/path2'], options)
57
+ expect(result).to be_nil
58
+ end
59
+ end
60
+
61
+ context 'when dry run is enabled' do
62
+ let(:options) do
63
+ double(
64
+ cloudfront_invalidate: true,
65
+ cloudfront_distribution_id: 'E1234567890123',
66
+ cloudfront_invalidate_all: false,
67
+ dry_run: true,
68
+ verbose: true
69
+ )
70
+ end
71
+
72
+ it 'shows what would be invalidated without making API calls' do
73
+ expect(described_class).to receive(:say_status).with(
74
+ 'Invalidating CloudFront distribution E1234567890123'
75
+ )
76
+ expect(described_class).to receive(:say_status).with(
77
+ match(/DRY RUN.*Would invalidate 2 paths/)
78
+ )
79
+ expect(described_class).to receive(:say_status).with(' /path1')
80
+ expect(described_class).to receive(:say_status).with(' /path2')
81
+ expect(Aws::CloudFront::Client).not_to receive(:new)
82
+
83
+ result = described_class.invalidate(['/path1', '/path2'], options)
84
+ expect(result).to be_nil
85
+ end
86
+ end
87
+
88
+ context 'when invalidating all paths' do
89
+ let(:options) do
90
+ double(
91
+ cloudfront_invalidate: true,
92
+ cloudfront_distribution_id: 'E1234567890123',
93
+ cloudfront_invalidate_all: true,
94
+ cloudfront_invalidation_batch_size: 1000,
95
+ cloudfront_wait: false,
96
+ aws_access_key_id: 'test_key',
97
+ aws_secret_access_key: 'test_secret',
98
+ aws_session_token: nil,
99
+ dry_run: false,
100
+ verbose: false
101
+ )
102
+ end
103
+
104
+ it 'invalidates all paths with /*' do
105
+ client = double('cloudfront_client')
106
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
107
+ expect(client).to receive(:create_invalidation).with({
108
+ distribution_id: 'E1234567890123',
109
+ invalidation_batch: {
110
+ paths: {
111
+ quantity: 1,
112
+ items: ['/*']
113
+ },
114
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
115
+ }
116
+ }).and_return(invalidation_response)
117
+
118
+ result = described_class.invalidate(['/path1', '/path2'], options)
119
+ expect(result).to eq(['I1234567890123'])
120
+ end
121
+ end
122
+
123
+ context 'when invalidating specific paths' do
124
+ it 'creates invalidation for the provided paths' do
125
+ client = double('cloudfront_client')
126
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
127
+ expect(client).to receive(:create_invalidation).with({
128
+ distribution_id: 'E1234567890123',
129
+ invalidation_batch: {
130
+ paths: {
131
+ quantity: 2,
132
+ items: ['/path1', '/path2']
133
+ },
134
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
135
+ }
136
+ }).and_return(invalidation_response)
137
+
138
+ result = described_class.invalidate(['/path1', '/path2'], options)
139
+ expect(result).to eq(['I1234567890123'])
140
+ end
141
+
142
+ it 'normalizes paths to start with /' do
143
+ client = double('cloudfront_client')
144
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
145
+ expect(client).to receive(:create_invalidation).with({
146
+ distribution_id: 'E1234567890123',
147
+ invalidation_batch: {
148
+ paths: {
149
+ quantity: 2,
150
+ items: ['/path1', '/path2']
151
+ },
152
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
153
+ }
154
+ }).and_return(invalidation_response)
155
+
156
+ result = described_class.invalidate(['path1', '/path2'], options)
157
+ expect(result).to eq(['I1234567890123'])
158
+ end
159
+
160
+ it 'removes duplicate paths' do
161
+ client = double('cloudfront_client')
162
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
163
+ expect(client).to receive(:create_invalidation).with({
164
+ distribution_id: 'E1234567890123',
165
+ invalidation_batch: {
166
+ paths: {
167
+ quantity: 2,
168
+ items: ['/path1', '/path2']
169
+ },
170
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
171
+ }
172
+ }).and_return(invalidation_response)
173
+
174
+ result = described_class.invalidate(['/path1', 'path1', '/path2'], options)
175
+ expect(result).to eq(['I1234567890123'])
176
+ end
177
+
178
+ it 'removes double slashes from paths' do
179
+ client = double('cloudfront_client')
180
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
181
+ expect(client).to receive(:create_invalidation).with({
182
+ distribution_id: 'E1234567890123',
183
+ invalidation_batch: {
184
+ paths: {
185
+ quantity: 1,
186
+ items: ['/path/to/file.html']
187
+ },
188
+ caller_reference: match(/middleman-s3_sync-\d+-[a-f0-9]{8}/)
189
+ }
190
+ }).and_return(invalidation_response)
191
+
192
+ result = described_class.invalidate(['//path//to//file.html'], options)
193
+ expect(result).to eq(['I1234567890123'])
194
+ end
195
+ end
196
+
197
+ context 'with large batch sizes' do
198
+ let(:options) do
199
+ double(
200
+ cloudfront_invalidate: true,
201
+ cloudfront_distribution_id: 'E1234567890123',
202
+ cloudfront_invalidate_all: false,
203
+ cloudfront_invalidation_batch_size: 2,
204
+ cloudfront_wait: false,
205
+ aws_access_key_id: 'test_key',
206
+ aws_secret_access_key: 'test_secret',
207
+ aws_session_token: nil,
208
+ dry_run: false,
209
+ verbose: false
210
+ )
211
+ end
212
+
213
+ it 'splits paths into multiple batches' do
214
+ client = double('cloudfront_client')
215
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
216
+ paths = ['/path1', '/path2', '/path3', '/path4']
217
+
218
+ expect(client).to receive(:create_invalidation).twice.and_return(invalidation_response)
219
+ expect(described_class).to receive(:sleep).with(1)
220
+
221
+ result = described_class.invalidate(paths, options)
222
+ expect(result).to eq(['I1234567890123', 'I1234567890123'])
223
+ end
224
+ end
225
+
226
+ context 'when CloudFront API returns an error' do
227
+ let(:error) { Aws::CloudFront::Errors::ServiceError.new(nil, 'Distribution not found') }
228
+
229
+ it 'handles API errors gracefully' do
230
+ client = double('cloudfront_client')
231
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
232
+ expect(client).to receive(:create_invalidation).and_raise(error)
233
+ expect(described_class).to receive(:say_status).with(
234
+ match(/Failed to create CloudFront invalidation.*Distribution not found/)
235
+ )
236
+
237
+ expect {
238
+ described_class.invalidate(['/path1'], options)
239
+ }.to raise_error(Aws::CloudFront::Errors::ServiceError)
240
+ end
241
+
242
+ context 'when verbose mode is enabled' do
243
+ let(:options) do
244
+ double(
245
+ cloudfront_invalidate: true,
246
+ cloudfront_distribution_id: 'E1234567890123',
247
+ cloudfront_invalidate_all: false,
248
+ cloudfront_invalidation_batch_size: 1000,
249
+ cloudfront_wait: false,
250
+ aws_access_key_id: 'test_key',
251
+ aws_secret_access_key: 'test_secret',
252
+ aws_session_token: nil,
253
+ dry_run: false,
254
+ verbose: true
255
+ )
256
+ end
257
+
258
+ it 'does not re-raise errors in verbose mode' do
259
+ client = double('cloudfront_client')
260
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
261
+ expect(client).to receive(:create_invalidation).and_raise(error)
262
+ expect(described_class).to receive(:say_status).with(
263
+ match(/Failed to create CloudFront invalidation/)
264
+ )
265
+
266
+ expect {
267
+ described_class.invalidate(['/path1'], options)
268
+ }.not_to raise_error
269
+ end
270
+ end
271
+ end
272
+
273
+ context 'with empty paths and invalidate_all false' do
274
+ it 'returns early without making API calls' do
275
+ expect(Aws::CloudFront::Client).not_to receive(:new)
276
+ result = described_class.invalidate([], options)
277
+ expect(result).to be_nil
278
+ end
279
+ end
280
+
281
+ context 'when cloudfront_wait is enabled' do
282
+ let(:options) do
283
+ double(
284
+ cloudfront_invalidate: true,
285
+ cloudfront_distribution_id: 'E1234567890123',
286
+ cloudfront_invalidate_all: false,
287
+ cloudfront_invalidation_batch_size: 1000,
288
+ cloudfront_wait: true,
289
+ aws_access_key_id: 'test_key',
290
+ aws_secret_access_key: 'test_secret',
291
+ aws_session_token: nil,
292
+ dry_run: false,
293
+ verbose: false
294
+ )
295
+ end
296
+
297
+ it 'waits for invalidation to complete' do
298
+ client = double('cloudfront_client')
299
+ allow(Aws::CloudFront::Client).to receive(:new).and_return(client)
300
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
301
+ expect(client).to receive(:wait_until).with(:invalidation_completed,
302
+ distribution_id: 'E1234567890123',
303
+ id: 'I1234567890123'
304
+ )
305
+ expect(described_class).to receive(:say_status).with(
306
+ 'Waiting for CloudFront invalidation(s) to complete...'
307
+ )
308
+ expect(described_class).to receive(:say_status).with(
309
+ 'CloudFront invalidation(s) completed successfully'
310
+ )
311
+
312
+ result = described_class.invalidate(['/path1'], options)
313
+ expect(result).to eq(['I1234567890123'])
314
+ end
315
+ end
316
+ end
317
+
318
+ describe 'CloudFront client configuration' do
319
+ it 'creates client with correct credentials' do
320
+ client = double('cloudfront_client')
321
+ expect(Aws::CloudFront::Client).to receive(:new).with({
322
+ region: 'us-east-1',
323
+ access_key_id: 'test_key',
324
+ secret_access_key: 'test_secret'
325
+ }).and_return(client)
326
+
327
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
328
+
329
+ described_class.invalidate(['/test'], options)
330
+ end
331
+
332
+ context 'with session token' do
333
+ let(:options) do
334
+ double(
335
+ cloudfront_invalidate: true,
336
+ cloudfront_distribution_id: 'E1234567890123',
337
+ cloudfront_invalidate_all: false,
338
+ cloudfront_invalidation_batch_size: 1000,
339
+ cloudfront_wait: false,
340
+ aws_access_key_id: 'test_key',
341
+ aws_secret_access_key: 'test_secret',
342
+ aws_session_token: 'test_token',
343
+ dry_run: false,
344
+ verbose: false
345
+ )
346
+ end
347
+
348
+ it 'includes session token in client configuration' do
349
+ client = double('cloudfront_client')
350
+ expect(Aws::CloudFront::Client).to receive(:new).with({
351
+ region: 'us-east-1',
352
+ access_key_id: 'test_key',
353
+ secret_access_key: 'test_secret',
354
+ session_token: 'test_token'
355
+ }).and_return(client)
356
+
357
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
358
+
359
+ described_class.invalidate(['/test'], options)
360
+ end
361
+ end
362
+
363
+ context 'without explicit credentials' do
364
+ let(:options) do
365
+ double(
366
+ cloudfront_invalidate: true,
367
+ cloudfront_distribution_id: 'E1234567890123',
368
+ cloudfront_invalidate_all: false,
369
+ cloudfront_invalidation_batch_size: 1000,
370
+ cloudfront_wait: false,
371
+ aws_access_key_id: nil,
372
+ aws_secret_access_key: nil,
373
+ aws_session_token: nil,
374
+ dry_run: false,
375
+ verbose: false
376
+ )
377
+ end
378
+
379
+ it 'creates client without explicit credentials (uses default chain)' do
380
+ client = double('cloudfront_client')
381
+ expect(Aws::CloudFront::Client).to receive(:new).with({
382
+ region: 'us-east-1'
383
+ }).and_return(client)
384
+
385
+ expect(client).to receive(:create_invalidation).and_return(invalidation_response)
386
+
387
+ described_class.invalidate(['/test'], options)
388
+ end
389
+ end
390
+ end
391
+ end
@@ -8,13 +8,39 @@ describe Middleman::S3Sync::Resource do
8
8
 
9
9
  let(:mm_resource) {
10
10
  double(
11
- destination_path: 'path/to/resource.html'
11
+ destination_path: 'path/to/resource.html',
12
+ content_type: 'text/html'
12
13
  )
13
14
  }
15
+
16
+ let(:s3_client) { instance_double(Aws::S3::Client) }
17
+ let(:s3_resource) { instance_double(Aws::S3::Resource) }
18
+ let(:bucket) { instance_double(Aws::S3::Bucket) }
19
+ let(:s3_object) { instance_double(Aws::S3::Object) }
20
+
14
21
  before do
15
22
  Middleman::S3Sync.s3_sync_options = options
23
+
16
24
  options.build_dir = "build"
17
25
  options.prefer_gzip = false
26
+ options.bucket = "test-bucket"
27
+ options.acl = "public-read"
28
+
29
+ allow(Aws::S3::Client).to receive(:new).and_return(s3_client)
30
+ allow(Aws::S3::Resource).to receive(:new).and_return(s3_resource)
31
+ allow(s3_resource).to receive(:bucket).and_return(bucket)
32
+ allow(bucket).to receive(:exists?).and_return(true)
33
+ allow(bucket).to receive(:object) do |path|
34
+ # Ensure path has no leading slash
35
+ path = path.sub(/^\//, '') if path.is_a?(String)
36
+ s3_object
37
+ end
38
+ allow(s3_object).to receive(:head).and_return(nil)
39
+ allow(s3_object).to receive(:put).and_return(true)
40
+ allow(s3_object).to receive(:delete).and_return(true)
41
+
42
+ # Allow Middleman::S3Sync to use our mocked bucket
43
+ allow(Middleman::S3Sync).to receive(:bucket).and_return(bucket)
18
44
  end
19
45
 
20
46
  context "a new resource" do
@@ -23,6 +49,7 @@ describe Middleman::S3Sync::Resource do
23
49
  context "without a prefix" do
24
50
  before do
25
51
  allow(File).to receive(:exist?).with('build/path/to/resource.html').and_return(true)
52
+ allow(File).to receive(:read).with('build/path/to/resource.html').and_return('test content')
26
53
  end
27
54
 
28
55
  its(:status) { is_expected.to eq :new }
@@ -31,11 +58,11 @@ describe Middleman::S3Sync::Resource do
31
58
  expect(resource).not_to be_remote
32
59
  end
33
60
 
34
- it "exits locally" do
61
+ it "exists locally" do
35
62
  expect(resource).to be_local
36
63
  end
37
64
 
38
- its(:path) { is_expected.to eq 'path/to/resource.html'}
65
+ its(:path) { is_expected.to eq 'path/to/resource.html' }
39
66
  its(:local_path) { is_expected.to eq 'build/path/to/resource.html' }
40
67
  its(:remote_path) { is_expected.to eq 'path/to/resource.html' }
41
68
  end
@@ -43,6 +70,7 @@ describe Middleman::S3Sync::Resource do
43
70
  context "with a prefix set" do
44
71
  before do
45
72
  allow(File).to receive(:exist?).with('build/path/to/resource.html').and_return(true)
73
+ allow(File).to receive(:read).with('build/path/to/resource.html').and_return('test content')
46
74
  options.prefix = "bob"
47
75
  end
48
76
 
@@ -62,6 +90,9 @@ describe Middleman::S3Sync::Resource do
62
90
  context "gzipped" do
63
91
  before do
64
92
  allow(File).to receive(:exist?).with('build/path/to/resource.html.gz').and_return(true)
93
+ allow(File).to receive(:read).with('build/path/to/resource.html.gz').and_return('gzipped content')
94
+ allow(File).to receive(:exist?).with('build/path/to/resource.html').and_return(true)
95
+ allow(File).to receive(:read).with('build/path/to/resource.html').and_return('test content')
65
96
  options.prefer_gzip = true
66
97
  end
67
98
 
@@ -82,12 +113,7 @@ describe Middleman::S3Sync::Resource do
82
113
  context "the file does not exist locally" do
83
114
  subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
84
115
 
85
- let(:remote) {
86
- double(
87
- key: 'path/to/resource.html',
88
- metadata: {}
89
- )
90
- }
116
+ let(:remote) { mock_s3_object('path/to/resource.html') }
91
117
 
92
118
  before do
93
119
  resource.full_s3_resource = remote
@@ -103,7 +129,7 @@ describe Middleman::S3Sync::Resource do
103
129
  expect(resource).to be_remote
104
130
  end
105
131
 
106
- it "exits locally" do
132
+ it "does not exist locally" do
107
133
  expect(resource).not_to be_local
108
134
  end
109
135
 
@@ -115,8 +141,9 @@ describe Middleman::S3Sync::Resource do
115
141
  context "with a prefix set" do
116
142
  before do
117
143
  allow(File).to receive(:exist?).with('build/path/to/resource.html').and_return(false)
118
- allow(remote).to receive(:key).and_return('bob/path/to/resource.html')
119
- options.prefix = "bob"
144
+ remote = mock_s3_object('bob/path/to/resource.html')
145
+ resource.full_s3_resource = remote
146
+ options.prefix = "bob/"
120
147
  end
121
148
 
122
149
  its(:status) { is_expected.to eq :deleted }
@@ -145,7 +172,7 @@ describe Middleman::S3Sync::Resource do
145
172
  expect(resource).to be_remote
146
173
  end
147
174
 
148
- it "exists locally" do
175
+ it "does not exist locally" do
149
176
  expect(resource).not_to be_local
150
177
  end
151
178
 
@@ -154,4 +181,42 @@ describe Middleman::S3Sync::Resource do
154
181
  its(:remote_path) { is_expected.to eq 'path/to/resource.html' }
155
182
  end
156
183
  end
184
+
185
+ context 'An ignored resource' do
186
+ context "that is local" do
187
+
188
+ subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
189
+
190
+ let(:mm_resource) {
191
+ double(
192
+ destination_path: 'ignored/path/to/resource.html',
193
+ content_type: 'text/html'
194
+ )
195
+ }
196
+
197
+ before do
198
+ allow(File).to receive(:exist?).with('build/ignored/path/to/resource.html').and_return(true)
199
+ allow(File).to receive(:read).with('build/ignored/path/to/resource.html').and_return('test content')
200
+ options.ignore_paths = [/^ignored/]
201
+ end
202
+
203
+ its(:status) { is_expected.to eq :ignored }
204
+ end
205
+
206
+ context "that is remote" do
207
+
208
+ subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
209
+
210
+ let(:remote) { mock_s3_object('ignored/path/to/resource.html') }
211
+
212
+ before do
213
+ resource.full_s3_resource = remote
214
+ options.ignore_paths = [/^ignored/]
215
+ end
216
+
217
+ its(:status) { is_expected.to eq :ignored }
218
+ end
219
+
220
+ end
221
+
157
222
  end
@@ -0,0 +1,132 @@
1
+ require 'spec_helper'
2
+ require 'middleman/s3_sync'
3
+
4
+ describe 'S3Sync CloudFront Integration' do
5
+ let(:app) { double('middleman_app') }
6
+ let(:s3_sync_options) do
7
+ double(
8
+ cloudfront_invalidate: true,
9
+ cloudfront_distribution_id: 'E1234567890123',
10
+ cloudfront_invalidate_all: false,
11
+ cloudfront_invalidation_batch_size: 1000,
12
+ aws_access_key_id: 'test_key',
13
+ aws_secret_access_key: 'test_secret',
14
+ aws_session_token: nil,
15
+ dry_run: false,
16
+ verbose: false,
17
+ delete: true,
18
+ bucket: 'test-bucket'
19
+ )
20
+ end
21
+
22
+ before do
23
+ allow(::Middleman::Application).to receive(:new).and_return(app)
24
+ allow(Middleman::S3Sync).to receive(:s3_sync_options).and_return(s3_sync_options)
25
+ allow(Middleman::S3Sync).to receive(:say_status)
26
+ allow(Middleman::S3Sync).to receive(:work_to_be_done?).and_return(true)
27
+ allow(Middleman::S3Sync).to receive(:update_bucket_versioning)
28
+ allow(Middleman::S3Sync).to receive(:update_bucket_website)
29
+ allow(Middleman::S3Sync).to receive(:ignore_resources)
30
+ allow(Middleman::S3Sync).to receive(:create_resources)
31
+ allow(Middleman::S3Sync).to receive(:update_resources)
32
+ allow(Middleman::S3Sync).to receive(:delete_resources)
33
+ allow(Middleman::S3Sync::CloudFront).to receive(:invalidate)
34
+ end
35
+
36
+ describe 'CloudFront invalidation integration' do
37
+ it 'calls CloudFront invalidation after sync operations' do
38
+ # Reset invalidation paths for this test
39
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
40
+
41
+ expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
42
+ [], # Initially empty, gets populated during resource operations
43
+ s3_sync_options
44
+ )
45
+
46
+ Middleman::S3Sync.sync
47
+ end
48
+
49
+ it 'calls CloudFront invalidation with collected paths' do
50
+ # Mock the methods to inject paths during sync
51
+ allow(Middleman::S3Sync).to receive(:create_resources) do
52
+ Middleman::S3Sync.add_invalidation_path('/updated/file.html')
53
+ end
54
+ allow(Middleman::S3Sync).to receive(:update_resources) do
55
+ Middleman::S3Sync.add_invalidation_path('/new/file.css')
56
+ end
57
+
58
+ expect(Middleman::S3Sync::CloudFront).to receive(:invalidate) do |paths, options|
59
+ expect(paths).to include('/updated/file.html')
60
+ expect(paths).to include('/new/file.css')
61
+ expect(options).to eq(s3_sync_options)
62
+ end
63
+
64
+ Middleman::S3Sync.sync
65
+ end
66
+
67
+ context 'when cloudfront_invalidate_all is true' do
68
+ let(:s3_sync_options) do
69
+ double(
70
+ cloudfront_invalidate: true,
71
+ cloudfront_distribution_id: 'E1234567890123',
72
+ cloudfront_invalidate_all: true,
73
+ bucket: 'test-bucket'
74
+ )
75
+ end
76
+
77
+ it 'still calls CloudFront invalidation even when no work is needed' do
78
+ allow(Middleman::S3Sync).to receive(:work_to_be_done?).and_return(false)
79
+
80
+ expect(Middleman::S3Sync::CloudFront).to receive(:invalidate).with(
81
+ [], s3_sync_options
82
+ )
83
+
84
+ Middleman::S3Sync.sync
85
+ end
86
+ end
87
+
88
+ context 'when CloudFront invalidation is disabled' do
89
+ let(:s3_sync_options) do
90
+ double(
91
+ cloudfront_invalidate: false,
92
+ bucket: 'test-bucket'
93
+ )
94
+ end
95
+
96
+ it 'does not call CloudFront invalidation' do
97
+ expect(Middleman::S3Sync::CloudFront).not_to receive(:invalidate)
98
+
99
+ Middleman::S3Sync.sync
100
+ end
101
+ end
102
+ end
103
+
104
+ describe 'path tracking during resource operations' do
105
+ before do
106
+ # Reset invalidation paths before each path tracking test
107
+ Middleman::S3Sync.instance_variable_set(:@invalidation_paths, [])
108
+ end
109
+
110
+ it 'adds paths to invalidation list when resources are processed' do
111
+ # Test the add_invalidation_path method directly
112
+ Middleman::S3Sync.add_invalidation_path('test/file.html')
113
+ Middleman::S3Sync.add_invalidation_path('images/photo.jpg')
114
+
115
+ expect(Middleman::S3Sync.invalidation_paths).to include('/test/file.html')
116
+ expect(Middleman::S3Sync.invalidation_paths).to include('/images/photo.jpg')
117
+ end
118
+
119
+ it 'normalizes paths when adding to invalidation list' do
120
+ Middleman::S3Sync.add_invalidation_path('no-leading-slash.html')
121
+
122
+ expect(Middleman::S3Sync.invalidation_paths).to include('/no-leading-slash.html')
123
+ end
124
+
125
+ it 'does not add duplicate paths' do
126
+ Middleman::S3Sync.add_invalidation_path('/same/path.html')
127
+ Middleman::S3Sync.add_invalidation_path('/same/path.html')
128
+
129
+ expect(Middleman::S3Sync.invalidation_paths.count('/same/path.html')).to eq(1)
130
+ end
131
+ end
132
+ end