|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +RSpec.describe Cron::PurgeSoftDeletedBlobsJob, type: :job do |
| 4 | + describe 'perform' do |
| 5 | + # Blobs are created against the real (disk) service, then the job runs against |
| 6 | + # a mocked OpenStack service — hence the helper, called from each example once |
| 7 | + # every blob exists. |
| 8 | + subject(:perform) do |
| 9 | + service = double('openstack service', container: 'bucket', name: :openstack) |
| 10 | + allow(service).to receive(:client).and_return(client) # reached via service.send(:client) |
| 11 | + allow(ActiveStorage::Blob).to receive(:service).and_return(service) |
| 12 | + described_class.perform_now |
| 13 | + end |
| 14 | + |
| 15 | + let(:client) { double('openstack client') } |
| 16 | + |
| 17 | + before do |
| 18 | + stub_const('ENV', ENV.to_hash.merge('PURGE_LATER_DELAY_IN_DAY' => '1')) |
| 19 | + allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => [] })) |
| 20 | + end |
| 21 | + |
| 22 | + # retention = PURGE_LATER_DELAY_IN_DAY = 1.day |
| 23 | + let(:blob_old) do |
| 24 | + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("old"), filename: "old.txt", content_type: "text/plain").tap do |blob| |
| 25 | + blob.update_columns(soft_delete_at: 2.days.ago, service_name: 'openstack') |
| 26 | + end |
| 27 | + end |
| 28 | + |
| 29 | + let(:blob_recent) do |
| 30 | + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("recent"), filename: "recent.txt", content_type: "text/plain").tap do |blob| |
| 31 | + blob.update_column(:soft_delete_at, 1.hour.ago) |
| 32 | + end |
| 33 | + end |
| 34 | + |
| 35 | + let(:blob_never_soft_deleted) do |
| 36 | + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("kept"), filename: "kept.txt", content_type: "text/plain") |
| 37 | + end |
| 38 | + |
| 39 | + before do |
| 40 | + blob_old |
| 41 | + blob_recent |
| 42 | + blob_never_soft_deleted |
| 43 | + end |
| 44 | + |
| 45 | + it 'destroys blobs soft-deleted long enough ago' do |
| 46 | + expect { perform }.to change { ActiveStorage::Blob.exists?(id: blob_old.id) }.from(true).to(false) |
| 47 | + end |
| 48 | + |
| 49 | + it 'keeps recently soft-deleted and never-soft-deleted blobs' do |
| 50 | + perform |
| 51 | + expect(ActiveStorage::Blob.exists?(id: blob_recent.id)).to be(true) |
| 52 | + expect(ActiveStorage::Blob.exists?(id: blob_never_soft_deleted.id)).to be(true) |
| 53 | + end |
| 54 | + |
| 55 | + it 'keeps blobs stored on another service' do |
| 56 | + blob_other_service = ActiveStorage::Blob |
| 57 | + .create_and_upload!(io: StringIO.new("other"), filename: "other.txt", content_type: "text/plain") |
| 58 | + .tap { it.update_columns(soft_delete_at: 2.days.ago, service_name: 'test') } |
| 59 | + |
| 60 | + perform |
| 61 | + |
| 62 | + expect(ActiveStorage::Blob.exists?(id: blob_other_service.id)).to be(true) |
| 63 | + end |
| 64 | + |
| 65 | + it 'does nothing when the storage service is not OpenStack' do |
| 66 | + allow(ActiveStorage::Blob).to receive(:service).and_return(double('disk service', name: :test)) |
| 67 | + |
| 68 | + expect { described_class.perform_now }.not_to change(ActiveStorage::Blob, :count) |
| 69 | + end |
| 70 | + |
| 71 | + it 'bulk-deletes the parent file (safety net against a missed X-Delete-At)' do |
| 72 | + expect(client).to receive(:delete_multiple_objects) |
| 73 | + .with('bucket', [blob_old.key]) |
| 74 | + .and_return(double(body: { 'Errors' => [] })) |
| 75 | + |
| 76 | + perform |
| 77 | + end |
| 78 | + |
| 79 | + context 'when a soft-deleted image has variants' do |
| 80 | + let!(:variant_blob) do |
| 81 | + ActiveStorage::Blob.create_and_upload!(io: StringIO.new("variant"), filename: "v.png", content_type: "image/png") |
| 82 | + end |
| 83 | + let!(:variant_record) { ActiveStorage::VariantRecord.create!(blob: blob_old, variation_digest: "digest") } |
| 84 | + let!(:image_attachment) { ActiveStorage::Attachment.create!(name: "image", record: variant_record, blob: variant_blob) } |
| 85 | + |
| 86 | + it 'bulk-deletes both variant and parent files, then drops every row' do |
| 87 | + expect(client).to receive(:delete_multiple_objects) |
| 88 | + .with('bucket', match_array([variant_blob.key, blob_old.key])) |
| 89 | + .and_return(double(body: { 'Errors' => [] })) |
| 90 | + |
| 91 | + perform |
| 92 | + |
| 93 | + expect(ActiveStorage::Blob.where(id: [blob_old.id, variant_blob.id])).not_to exist |
| 94 | + expect(ActiveStorage::VariantRecord.where(id: variant_record.id)).not_to exist |
| 95 | + expect(ActiveStorage::Attachment.where(id: image_attachment.id)).not_to exist |
| 96 | + end |
| 97 | + |
| 98 | + it 'reports per-object bulk delete errors to Sentry' do |
| 99 | + errors = [["bucket/#{variant_blob.key}", "409 Conflict"]] |
| 100 | + allow(client).to receive(:delete_multiple_objects).and_return(double(body: { 'Errors' => errors })) |
| 101 | + |
| 102 | + expect(Sentry).to receive(:capture_message).with("Bulk delete errors", extra: { errors: }) |
| 103 | + |
| 104 | + perform |
| 105 | + end |
| 106 | + end |
| 107 | + end |
| 108 | +end |
0 commit comments