From 40c8f3d20e2787891c73f46d09d48832ce58d432 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 26 Feb 2026 12:13:02 -0700 Subject: [PATCH] Add `delete_from_s3` action for S3 object deletion Complement the existing `upload_to_s3` action with a `delete_from_s3` action that supports single-key deletion, bulk deletion by prefix, age-based filtering (`older_than_days`), and a `dry_run` preview mode. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- .../actions/common/delete_from_s3.rb | 260 +++++++++ spec/delete_from_s3_spec.rb | 516 ++++++++++++++++++ 3 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/common/delete_from_s3.rb create mode 100644 spec/delete_from_s3_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index e1c95a2e7..c7cb740de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Add `delete_from_s3` action to delete objects from an S3 bucket, complementing the existing `upload_to_s3` action. Supports single-key deletion, bulk deletion by prefix, age-based filtering via `older_than_days`, and a `dry_run` mode. [#xxx] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/delete_from_s3.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/delete_from_s3.rb new file mode 100644 index 000000000..a6f91917f --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/delete_from_s3.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'fastlane/action' + +module Fastlane + module Actions + class DeleteFromS3Action < Action + def self.run(params) + bucket = params[:bucket] + key = params[:key] + prefix = params[:prefix] + older_than_days = params[:older_than_days] + dry_run = params[:dry_run] + silent = params[:silent] + + UI.user_error!('You must provide either :key or :prefix') if key.nil? && prefix.nil? + UI.user_error!('You cannot use :dry_run and :silent at the same time') if dry_run && silent + + if prefix + delete_by_prefix(bucket: bucket, prefix: prefix, older_than_days: older_than_days, dry_run: dry_run, silent: silent) + else + delete_by_key(bucket: bucket, key: key, older_than_days: older_than_days, dry_run: dry_run, silent: silent, fail_if_not_found: params[:fail_if_not_found]) + end + end + + def self.delete_by_key(bucket:, key:, older_than_days:, dry_run:, silent:, fail_if_not_found:) + client = Aws::S3::Client.new + metadata = head_object(client: client, bucket: bucket, key: key) + + if metadata.nil? + if fail_if_not_found + UI.user_error!("File not found in S3 bucket #{bucket} at #{key}") + else + UI.important("File not found in S3 bucket #{bucket} at #{key}. Skipping deletion.") unless silent + return nil + end + end + + if older_than_days + last_modified = metadata[:last_modified] + cutoff = Time.now.utc - (older_than_days * 86_400) + + if last_modified >= cutoff + UI.message("Skipping #{key} - last modified #{last_modified.iso8601}, not older than #{older_than_days} days") unless silent + return nil + end + + UI.message("#{key} last modified #{last_modified.iso8601}, older than #{older_than_days} days") unless silent + end + + if dry_run + UI.important("Dry run: would delete #{key}") unless silent + return key + end + + UI.message("Deleting #{key} from S3 bucket #{bucket}") unless silent + + client.delete_object(bucket: bucket, key: key) + + UI.success('Deletion complete') unless silent + key + rescue Aws::S3::Errors::ServiceError => e + UI.crash!("Unable to delete file from S3: #{e.message}") + end + + def self.delete_by_prefix(bucket:, prefix:, older_than_days:, dry_run:, silent:) + client = Aws::S3::Client.new + cutoff = older_than_days ? Time.now.utc - (older_than_days * 86_400) : nil + + UI.message("Listing objects in s3://#{bucket}/#{prefix}...") unless silent + + to_delete = [] + skipped = 0 + continuation_token = nil + + loop do + response = client.list_objects_v2( + bucket: bucket, + prefix: prefix, + continuation_token: continuation_token + ) + + response.contents.each do |object| + if cutoff && object.last_modified >= cutoff + UI.message("Skipping #{object.key} - last modified #{object.last_modified.iso8601}, not older than #{older_than_days} days") unless silent + skipped += 1 + next + end + + UI.message("Will delete #{object.key} (last modified #{object.last_modified.iso8601})") unless silent + to_delete << object.key + end + + break unless response.is_truncated + + continuation_token = response.next_continuation_token + end + + if to_delete.empty? + message = if skipped.positive? + "No objects eligible for deletion under prefix '#{prefix}' (#{skipped} skipped as too recent)" + else + "No objects found under prefix '#{prefix}'" + end + UI.message(message) unless silent + return [] + end + + if dry_run + UI.important("Dry run: would delete #{to_delete.length} object(s), skipped #{skipped}") unless silent + return to_delete + end + + UI.message("Deleting #{to_delete.length} object(s)...") unless silent + delete_objects_in_batches(client: client, bucket: bucket, keys: to_delete, silent: silent) + UI.success("Deleted #{to_delete.length} object(s) from s3://#{bucket}/#{prefix} (skipped #{skipped})") unless silent + + to_delete + rescue Aws::S3::Errors::ServiceError => e + UI.crash!("Unable to delete files from S3: #{e.message}") + end + + def self.delete_objects_in_batches(client:, bucket:, keys:, silent: false) + keys.each_slice(1000) do |batch| + response = client.delete_objects( + bucket: bucket, + delete: { + objects: batch.map { |key| { key: key } }, + quiet: true + } + ) + + next if response.errors.nil? || response.errors.empty? + + response.errors.each do |error| + UI.error("Failed to delete #{error.key}: #{error.code} - #{error.message}") unless silent + end + failed_keys = response.errors.map(&:key) + succeeded = batch.reject { |key| failed_keys.include?(key) } + UI.message("#{succeeded.length} object(s) in this batch were deleted successfully") unless succeeded.empty? || silent + UI.user_error!("#{response.errors.length} object(s) failed to delete") + end + end + + # Returns the head_object response hash, or nil if the object does not exist. + def self.head_object(client:, bucket:, key:) + client.head_object(bucket: bucket, key: key) + rescue Aws::S3::Errors::NotFound + nil + end + + def self.description + 'Deletes files from S3' + end + + def self.authors + ['Automattic'] + end + + def self.return_value + 'The deleted key or nil (single-key mode), or an array of deleted keys (prefix mode, empty array if none matched). ' \ + 'In dry-run mode, returns what would have been deleted.' + end + + def self.details + <<~DETAILS + Deletes objects from an S3 bucket, either by exact key or by key prefix. + + In single-key mode (:key), deletes one object. In prefix mode (:prefix), + lists all matching objects and batch-deletes them. Both modes support + :older_than_days to only delete objects older than a given number of days. + + Use :dry_run to preview what would be deleted without making changes. + Use :silent to suppress per-object log output. + DETAILS + end + + def self.example_code + [ + '# Delete a single object + delete_from_s3(bucket: "my-bucket", key: "path/to/file.zip")', + '# Delete a single object only if older than 90 days + delete_from_s3(bucket: "my-bucket", key: "path/to/file.zip", older_than_days: 90)', + '# Delete all objects under a prefix older than 180 days + delete_from_s3(bucket: "my-bucket", prefix: "trunk/", older_than_days: 180)', + '# Dry run: see what would be deleted without deleting + delete_from_s3(bucket: "my-bucket", prefix: "trunk/", older_than_days: 180, dry_run: true)', + ] + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :bucket, + description: 'The bucket containing the file(s) to delete', + optional: false, + type: String, + verify_block: proc { |bucket| UI.user_error!('You must provide a valid bucket name') if bucket.empty? } + ), + FastlaneCore::ConfigItem.new( + key: :key, + description: 'The exact key of the object to delete. Mutually exclusive with :prefix', + optional: true, + conflicting_options: [:prefix], + type: String, + verify_block: proc { |key| + next if key.is_a?(String) && !key.empty? + + UI.user_error!('You must provide a valid key') + } + ), + FastlaneCore::ConfigItem.new( + key: :prefix, + description: 'A key prefix - all matching objects will be deleted. Mutually exclusive with :key', + optional: true, + conflicting_options: [:key], + type: String, + verify_block: proc { |prefix| + next if prefix.is_a?(String) && !prefix.empty? + + UI.user_error!('You must provide a valid prefix') + } + ), + FastlaneCore::ConfigItem.new( + key: :older_than_days, + description: 'Only delete objects whose last_modified is older than this many days', + optional: true, + type: Integer, + verify_block: proc { |days| UI.user_error!('older_than_days must be a positive integer') unless days.positive? } + ), + FastlaneCore::ConfigItem.new( + key: :dry_run, + description: 'When true, log what would be deleted without actually deleting', + optional: true, + default_value: false, + type: Boolean + ), + FastlaneCore::ConfigItem.new( + key: :silent, + description: 'When true, suppress per-object log messages', + optional: true, + default_value: false, + type: Boolean + ), + FastlaneCore::ConfigItem.new( + key: :fail_if_not_found, + description: 'Whether to fail with `user_error!` if the file does not exist (single-key mode only)', + optional: true, + default_value: true, + type: Boolean + ), + ] + end + + def self.is_supported?(platform) + true + end + end + end +end diff --git a/spec/delete_from_s3_spec.rb b/spec/delete_from_s3_spec.rb new file mode 100644 index 000000000..ce8454df1 --- /dev/null +++ b/spec/delete_from_s3_spec.rb @@ -0,0 +1,516 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +describe Fastlane::Actions::DeleteFromS3Action do + let(:client) { instance_double(Aws::S3::Client) } + let(:test_bucket) { 'a8c-wpmrt-unit-tests-bucket' } + + before do + allow(Aws::S3::Client).to receive(:new).and_return(client) + end + + def stub_head_object(key, exists: true, last_modified: Time.now.utc) + if exists + allow(client).to(receive(:head_object)) + .with(bucket: test_bucket, key: key) + .and_return(Aws::S3::Types::HeadObjectOutput.new(content_length: 1, last_modified: last_modified)) + else + allow(client).to(receive(:head_object)) + .with(bucket: test_bucket, key: key) + .and_raise(Aws::S3::Errors::NotFound.new(nil, 'Not Found')) + end + end + + def s3_object_stub(key:, last_modified:, size: 1024) + instance_double(Aws::S3::Types::Object, key: key, last_modified: last_modified, size: size) + end + + def list_response_stub(contents:, is_truncated: false, next_continuation_token: nil) + instance_double( + Aws::S3::Types::ListObjectsV2Output, + contents: contents, + is_truncated: is_truncated, + next_continuation_token: next_continuation_token + ) + end + + def delete_objects_response_stub(errors: []) + instance_double(Aws::S3::Types::DeleteObjectsOutput, errors: errors) + end + + describe 'single-key deletion' do + it 'deletes the object from S3' do + stub_head_object('my-key') + expect(client).to receive(:delete_object).with(bucket: test_bucket, key: 'my-key') + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key' + ) + + expect(result).to eq('my-key') + end + + it 'fails by default when the file is not found' do + stub_head_object('missing-key', exists: false) + + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'missing-key' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, "File not found in S3 bucket #{test_bucket} at missing-key") + end + + it 'skips deletion when fail_if_not_found is false' do + stub_head_object('missing-key', exists: false) + + warnings = [] + allow(FastlaneCore::UI).to receive(:important) { |message| warnings << message } + + expect(client).not_to receive(:delete_object) + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'missing-key', + fail_if_not_found: false + ) + + expect(result).to be_nil + expect(warnings).to eq(["File not found in S3 bucket #{test_bucket} at missing-key. Skipping deletion."]) + end + + it 'returns the key after successful deletion' do + stub_head_object('my-key') + allow(client).to receive(:delete_object) + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key' + ) + + expect(result).to eq('my-key') + end + end + + describe 'single-key deletion with older_than_days' do + it 'deletes an object older than the threshold' do + old_time = Time.now.utc - (200 * 86_400) + stub_head_object('my-key', last_modified: old_time) + + expect(client).to receive(:delete_object).with(bucket: test_bucket, key: 'my-key') + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + older_than_days: 180 + ) + + expect(result).to eq('my-key') + end + + it 'skips an object just inside the cutoff boundary' do + # 179 days ago is within the 180-day window, so it should be skipped + just_inside_cutoff = Time.now.utc - (179 * 86_400) + stub_head_object('my-key', last_modified: just_inside_cutoff) + + expect(client).not_to receive(:delete_object) + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + older_than_days: 180 + ) + + expect(result).to be_nil + end + + it 'skips an object newer than the threshold' do + recent_time = Time.now.utc - (10 * 86_400) + stub_head_object('my-key', last_modified: recent_time) + + expect(client).not_to receive(:delete_object) + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + older_than_days: 180 + ) + + expect(result).to be_nil + end + + it 'logs what it would delete in dry_run mode' do + old_time = Time.now.utc - (200 * 86_400) + stub_head_object('my-key', last_modified: old_time) + + warnings = [] + allow(FastlaneCore::UI).to receive(:important) { |message| warnings << message } + + expect(client).not_to receive(:delete_object) + + result = run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + older_than_days: 180, + dry_run: true + ) + + expect(result).to eq('my-key') + expect(warnings).to include('Dry run: would delete my-key') + end + end + + describe 'prefix-based deletion' do + it 'deletes all objects matching the prefix' do + now = Time.now.utc + obj1 = s3_object_stub(key: 'trunk/build-1.zip', last_modified: now - (10 * 86_400)) + obj2 = s3_object_stub(key: 'trunk/build-2.zip', last_modified: now - (5 * 86_400)) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [obj1, obj2])) + + allow(client).to receive(:delete_objects).with( + bucket: test_bucket, + delete: { + objects: [{ key: 'trunk/build-1.zip' }, { key: 'trunk/build-2.zip' }], + quiet: true + } + ).and_return(delete_objects_response_stub) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + + expect(result).to contain_exactly('trunk/build-1.zip', 'trunk/build-2.zip') + end + + it 'returns an empty array when no objects match' do + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [])) + + expect(client).not_to receive(:delete_objects) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + + expect(result).to eq([]) + end + + it 'handles pagination across multiple pages' do + now = Time.now.utc + obj1 = s3_object_stub(key: 'trunk/page1.zip', last_modified: now) + obj2 = s3_object_stub(key: 'trunk/page2.zip', last_modified: now) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [obj1], is_truncated: true, next_continuation_token: 'token-1')) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: 'token-1') + .and_return(list_response_stub(contents: [obj2])) + + allow(client).to receive(:delete_objects).with( + bucket: test_bucket, + delete: { + objects: [{ key: 'trunk/page1.zip' }, { key: 'trunk/page2.zip' }], + quiet: true + } + ).and_return(delete_objects_response_stub) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + + expect(result).to contain_exactly('trunk/page1.zip', 'trunk/page2.zip') + end + end + + describe 'prefix-based deletion with older_than_days' do + it 'deletes only objects older than the threshold' do + now = Time.now.utc + old_obj = s3_object_stub(key: 'trunk/old-build.zip', last_modified: now - (200 * 86_400)) + new_obj = s3_object_stub(key: 'trunk/recent-build.zip', last_modified: now - (10 * 86_400)) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [old_obj, new_obj])) + + allow(client).to receive(:delete_objects).with( + bucket: test_bucket, + delete: { + objects: [{ key: 'trunk/old-build.zip' }], + quiet: true + } + ).and_return(delete_objects_response_stub) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/', + older_than_days: 180 + ) + + expect(result).to eq(['trunk/old-build.zip']) + end + + it 'logs skipped count when all objects are too recent' do + now = Time.now.utc + new_obj = s3_object_stub(key: 'trunk/recent-build.zip', last_modified: now - (10 * 86_400)) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [new_obj])) + + messages = [] + allow(FastlaneCore::UI).to receive(:message) { |msg| messages << msg } + + expect(client).not_to receive(:delete_objects) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/', + older_than_days: 180 + ) + + expect(result).to eq([]) + expect(messages).to include("No objects eligible for deletion under prefix 'trunk/' (1 skipped as too recent)") + end + + it 'does not delete in dry_run mode' do + now = Time.now.utc + old_obj = s3_object_stub(key: 'trunk/old-build.zip', last_modified: now - (200 * 86_400)) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [old_obj])) + + expect(client).not_to receive(:delete_objects) + + result = run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/', + older_than_days: 180, + dry_run: true + ) + + expect(result).to eq(['trunk/old-build.zip']) + end + end + + describe 'batch deletion' do + it 'splits into batches of 1000' do + now = Time.now.utc + objects = (1..1500).map { |i| s3_object_stub(key: "trunk/build-#{i}.zip", last_modified: now) } + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: objects)) + + batch_sizes = [] + allow(client).to receive(:delete_objects) do |params| + batch_sizes << params[:delete][:objects].length + delete_objects_response_stub + end + + run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + + expect(batch_sizes).to eq([1000, 500]) + end + end + + describe 'delete_objects error handling' do + it 'raises when delete_objects returns errors' do + now = Time.now.utc + obj = s3_object_stub(key: 'trunk/build.zip', last_modified: now) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [obj])) + + error = instance_double(Aws::S3::Types::Error, key: 'trunk/build.zip', code: 'AccessDenied', message: 'Access Denied') + allow(client).to receive(:delete_objects).and_return(delete_objects_response_stub(errors: [error])) + + expect do + run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, '1 object(s) failed to delete') + end + end + + describe 'S3 service error handling' do + it 'crashes on S3 service error during single-key deletion' do + stub_head_object('my-key') + allow(client).to receive(:delete_object).and_raise(Aws::S3::Errors::ServiceError.new(nil, 'Internal error')) + + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneCrash, /Unable to delete file from S3: Internal error/) + end + + it 'crashes on non-NotFound S3 error during head_object' do + allow(client).to(receive(:head_object)) + .with(bucket: test_bucket, key: 'my-key') + .and_raise(Aws::S3::Errors::ServiceError.new(nil, 'Forbidden')) + + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneCrash, /Unable to delete file from S3: Forbidden/) + end + + it 'crashes on S3 service error during prefix listing' do + allow(client).to receive(:list_objects_v2).and_raise(Aws::S3::Errors::ServiceError.new(nil, 'Access denied')) + + expect do + run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneCrash, /Unable to delete files from S3: Access denied/) + end + end + + describe 'silent mode' do + it 'suppresses per-object log messages in single-key mode' do + stub_head_object('my-key') + expect(client).to receive(:delete_object).with(bucket: test_bucket, key: 'my-key') + + messages = [] + allow(FastlaneCore::UI).to receive(:message) { |msg| messages << msg } + allow(FastlaneCore::UI).to receive(:success) { |msg| messages << msg } + + run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + silent: true + ) + + action_messages = messages.reject { |m| m.include?('Driving the lane') } + expect(action_messages).to be_empty + end + + it 'suppresses per-object log messages in prefix mode' do + now = Time.now.utc + obj = s3_object_stub(key: 'trunk/build.zip', last_modified: now) + + allow(client).to receive(:list_objects_v2) + .with(bucket: test_bucket, prefix: 'trunk/', continuation_token: nil) + .and_return(list_response_stub(contents: [obj])) + allow(client).to receive(:delete_objects).and_return(delete_objects_response_stub) + + messages = [] + allow(FastlaneCore::UI).to receive(:message) { |msg| messages << msg } + allow(FastlaneCore::UI).to receive(:success) { |msg| messages << msg } + + run_described_fastlane_action( + bucket: test_bucket, + prefix: 'trunk/', + silent: true + ) + + action_messages = messages.reject { |m| m.include?('Driving the lane') } + expect(action_messages).to be_empty + end + + it 'suppresses not-found message when combined with fail_if_not_found: false' do + stub_head_object('missing-key', exists: false) + + warnings = [] + allow(FastlaneCore::UI).to receive(:important) { |msg| warnings << msg } + + run_described_fastlane_action( + bucket: test_bucket, + key: 'missing-key', + fail_if_not_found: false, + silent: true + ) + + expect(warnings).to be_empty + end + + it 'rejects dry_run and silent used together' do + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + dry_run: true, + silent: true + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You cannot use :dry_run and :silent at the same time') + end + end + + describe 'invalid parameters' do + it 'fails if bucket is empty' do + expect do + run_described_fastlane_action( + bucket: '', + key: 'key' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide a valid bucket name') + end + + it 'fails if key is empty' do + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: '' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide a valid key') + end + + it 'fails if prefix is empty' do + expect do + run_described_fastlane_action( + bucket: test_bucket, + prefix: '' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide a valid prefix') + end + + it 'fails if older_than_days is not positive' do + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'key', + older_than_days: 0 + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'older_than_days must be a positive integer') + end + + it 'fails if both key and prefix are provided' do + expect do + run_described_fastlane_action( + bucket: test_bucket, + key: 'my-key', + prefix: 'trunk/' + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Unresolved conflict between options/) + end + + it 'fails if neither key nor prefix is provided' do + expect do + run_described_fastlane_action( + bucket: test_bucket + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, 'You must provide either :key or :prefix') + end + end +end