From 7d316b836975509ca787d1388733ba456a296b05 Mon Sep 17 00:00:00 2001 From: Frederic Merizen Date: Tue, 26 Mar 2019 21:41:26 +0100 Subject: [PATCH] [#2180] High-level PJ to champ PJ migration service --- ...to_champ_piece_jointe_migration_service.rb | 93 ++++++++++ ...amp_piece_jointe_migration_service_spec.rb | 170 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 app/services/piece_justificative_to_champ_piece_jointe_migration_service.rb create mode 100644 spec/services/piece_justificative_to_champ_piece_jointe_migration_service_spec.rb diff --git a/app/services/piece_justificative_to_champ_piece_jointe_migration_service.rb b/app/services/piece_justificative_to_champ_piece_jointe_migration_service.rb new file mode 100644 index 000000000..45910f3f9 --- /dev/null +++ b/app/services/piece_justificative_to_champ_piece_jointe_migration_service.rb @@ -0,0 +1,93 @@ +class PieceJustificativeToChampPieceJointeMigrationService + def initialize(**params) + params.each do |key, value| + instance_variable_set("@#{key}", value) + end + end + + def ensure_correct_storage_configuration! + storage_service.ensure_openstack_copy_possible!(PieceJustificativeUploader) + end + + def convert_procedure_pjs_to_champ_pjs(procedure) + types_de_champ_pj = PiecesJustificativesService.types_pj_as_types_de_champ(procedure) + populate_champs_pjs!(procedure, types_de_champ_pj) + + # Only destroy the old types PJ once everything has been safely migrated to + # champs PJs. Destroying the types PJ will cascade and destroy the PJs, + # and delete the linked objects from remote storage. This means that no other + # cleanup action is required. + procedure.types_de_piece_justificative.destroy_all + end + + def storage_service + @storage_service ||= CarrierwaveActiveStorageMigrationService.new + end + + def populate_champs_pjs!(procedure, types_de_champ_pj) + procedure.types_de_champ += types_de_champ_pj + + # Unscope to make sure all dossiers are migrated, even the soft-deleted ones + procedure.dossiers.unscope(where: :hidden_at).find_each do |dossier| + champs_pj = types_de_champ_pj.map(&:build_champ) + dossier.champs += champs_pj + + champs_pj.each do |champ| + type_pj_id = champ.type_de_champ.old_pj&.fetch('stable_id', nil) + pj = dossier.retrieve_last_piece_justificative_by_type(type_pj_id) + if pj.present? + convert_pj_to_champ!(pj, champ) + end + end + end + rescue + # If anything goes wrong, we roll back the migration by destroying the newly created + # types de champ, champs blobs and attachments. + types_de_champ_pj.each do |type_champ| + type_champ.champ.each { |c| c.piece_justificative_file.purge } + type_champ.destroy + end + + # Reraise the exception to abort the migration. + raise + end + + def convert_pj_to_champ!(pj, champ) + blob = make_blob(pj) + + # Upload the file before creating the attachment to make sure MIME type + # identification doesn’t fail. + storage_service.copy_from_carrierwave_to_active_storage!(pj.content.path, blob) + attachment = storage_service.make_attachment(champ, 'piece_justificative_file', blob) + + # By reloading, we force ActiveStorage to look at the attachment again, and see + # that one exists now. We do this so that, if we need to roll back and destroy the champ, + # the blob, the attachment and the actual file on OpenStack also get deleted. + champ.reload + rescue + # Destroy partially attached object that the more general rescue in `populate_champs_pjs!` + # might not be able to handle. + + if blob&.key.present? + begin + storage_service.delete_from_active_storage!(blob) + rescue => e + # The cleanup attempt failed, perhaps because the object had not been + # successfully copied to the Active Storage bucket yet. + # Continue trying to clean up the rest anyway. + pp e + end + end + + blob&.destroy + attachment&.destroy + champ.reload + + # Reraise the exception to abort the migration. + raise + end + + def make_blob(pj) + storage_service.make_blob(pj.content, pj.updated_at.iso8601, filename: pj.original_filename) + end +end diff --git a/spec/services/piece_justificative_to_champ_piece_jointe_migration_service_spec.rb b/spec/services/piece_justificative_to_champ_piece_jointe_migration_service_spec.rb new file mode 100644 index 000000000..8ebfd4647 --- /dev/null +++ b/spec/services/piece_justificative_to_champ_piece_jointe_migration_service_spec.rb @@ -0,0 +1,170 @@ +require 'spec_helper' + +describe PieceJustificativeToChampPieceJointeMigrationService do + let(:service) { PieceJustificativeToChampPieceJointeMigrationService.new(storage_service: storage_service) } + let(:storage_service) { CarrierwaveActiveStorageMigrationService.new } + let(:pj_uploader) { class_double(PieceJustificativeUploader) } + let(:pj_service) { class_double(PiecesJustificativesService) } + + let(:procedure) { create(:procedure, types_de_piece_justificative: types_pj) } + let(:types_pj) { [create(:type_de_piece_justificative)] } + + let!(:dossier) do + create( + :dossier, + procedure: procedure, + pieces_justificatives: pjs + ) + end + + let(:pjs) { [] } + + def make_pjs + types_pj.map do |tpj| + create(:piece_justificative, :contrat, type_de_piece_justificative: tpj) + end + end + + def expect_storage_service_to_convert_object + expect(storage_service).to receive(:make_blob) + expect(storage_service).to receive(:copy_from_carrierwave_to_active_storage!) + expect(storage_service).to receive(:make_attachment) + end + + context 'when conversion succeeds' do + context 'for the procedure' do + it 'types de champ are created for the "pièces jointes" header and for each PJ' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { procedure.types_de_champ.count } + .by(types_pj.count + 1) + end + + it 'the old types de pj are removed' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { procedure.types_de_piece_justificative.count } + .to(0) + end + end + + context 'for the dossier' do + let(:pjs) { make_pjs } + + before { expect_storage_service_to_convert_object } + + it 'champs are created for the "pièces jointes" header and for each PJ' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { dossier.champs.count } + .by(types_pj.count + 1) + end + + it 'the old pjs are removed' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { dossier.pieces_justificatives.count } + .to(0) + end + end + + context 'when the dossier is soft-deleted it still gets converted' do + let(:pjs) { make_pjs } + + let!(:dossier) do + create( + :dossier, + procedure: procedure, + pieces_justificatives: pjs, + hidden_at: Time.zone.now + ) + end + + before { expect_storage_service_to_convert_object } + + it 'champs are created for the "pièces jointes" header and for each PJ' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { dossier.champs.count } + .by(types_pj.count + 1) + end + + it 'the old pjs are removed' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to change { dossier.pieces_justificatives.count } + .to(0) + end + end + + context 'when there are several pjs for one type' do + let(:pjs) { make_pjs + make_pjs } + + it 'only converts the most recent PJ for each type PJ' do + expect(storage_service).to receive(:make_blob).exactly(types_pj.count) + expect(storage_service).to receive(:copy_from_carrierwave_to_active_storage!).exactly(types_pj.count) + expect(storage_service).to receive(:make_attachment).exactly(types_pj.count) + + service.convert_procedure_pjs_to_champ_pjs(procedure) + end + end + end + + context 'cleanup when conversion fails' do + let(:pjs) { make_pjs } + + let!(:failing_dossier) do + create( + :dossier, + procedure: procedure, + pieces_justificatives: make_pjs + ) + end + + before do + allow(storage_service).to receive(:checksum).and_return('cafe') + allow(storage_service).to receive(:fix_content_type) + + expect(storage_service).to receive(:copy_from_carrierwave_to_active_storage!) + expect(storage_service).to receive(:copy_from_carrierwave_to_active_storage!) + .and_raise('LOL no!') + + expect(storage_service).to receive(:delete_from_active_storage!) + end + + def try_convert(procedure) + service.convert_procedure_pjs_to_champ_pjs(procedure) + rescue => e + e + end + + it 'passes on the exception' do + expect { service.convert_procedure_pjs_to_champ_pjs(procedure) } + .to raise_error('LOL no!') + end + + it 'does not create champs' do + expect { try_convert(procedure) } + .not_to change { dossier.champs.count } + end + + it 'does not remove any old pjs' do + expect { try_convert(procedure) } + .not_to change { dossier.pieces_justificatives.count } + end + + it 'does not creates types de champ' do + expect { try_convert(procedure) } + .not_to change { procedure.types_de_champ.count } + end + + it 'does not remove old types de pj' do + expect { try_convert(procedure) } + .not_to change { procedure.types_de_piece_justificative.count } + end + + it 'does not leave stale blobs behind' do + expect { try_convert(procedure) } + .not_to change { ActiveStorage::Blob.count } + end + + it 'does not leave stale attachments behind' do + expect { try_convert(procedure) } + .not_to change { ActiveStorage::Attachment.count } + end + end +end