From 79218f97bbbfeba9f486d70732e28d1b1b631034 Mon Sep 17 00:00:00 2001 From: Stef Schenkelaars Date: Fri, 24 Jul 2020 14:32:43 +0200 Subject: [PATCH] Add have_attached matcher for ActiveStorage (#1102) --- lib/shoulda/matchers/active_record.rb | 1 + .../active_record/have_attached_matcher.rb | 147 ++++++++++ .../unit/helpers/active_record_versions.rb | 4 + spec/support/unit/helpers/rails_versions.rb | 4 + .../have_attached_matcher_spec.rb | 272 ++++++++++++++++++ 5 files changed, 428 insertions(+) create mode 100644 lib/shoulda/matchers/active_record/have_attached_matcher.rb create mode 100644 spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb diff --git a/lib/shoulda/matchers/active_record.rb b/lib/shoulda/matchers/active_record.rb index c0bdd2ed6..9c97d7078 100644 --- a/lib/shoulda/matchers/active_record.rb +++ b/lib/shoulda/matchers/active_record.rb @@ -23,6 +23,7 @@ require "shoulda/matchers/active_record/define_enum_for_matcher" require "shoulda/matchers/active_record/uniqueness" require "shoulda/matchers/active_record/validate_uniqueness_of_matcher" +require "shoulda/matchers/active_record/have_attached_matcher" module Shoulda module Matchers diff --git a/lib/shoulda/matchers/active_record/have_attached_matcher.rb b/lib/shoulda/matchers/active_record/have_attached_matcher.rb new file mode 100644 index 000000000..32a74fa0b --- /dev/null +++ b/lib/shoulda/matchers/active_record/have_attached_matcher.rb @@ -0,0 +1,147 @@ +module Shoulda + module Matchers + module ActiveRecord + def have_one_attached(name) + HaveAttachedMatcher.new(:one, name) + end + + def have_many_attached(name) + HaveAttachedMatcher.new(:many, name) + end + + # @private + class HaveAttachedMatcher + attr_reader :name + + def initialize(macro, name) + @macro = macro + @name = name + end + + def description + "have a has_#{macro}_attached called #{name}" + end + + def failure_message + <<-MESSAGE +Expected #{expectation}, but this could not be proved. + #{@failure} + MESSAGE + end + + def failure_message_when_negated + <<-MESSAGE +Did not expect #{expectation}, but it does. + MESSAGE + end + + def expectation + "#{model_class.name} to #{description}" + end + + def matches?(subject) + @subject = subject + reader_attribute_exists? && + writer_attribute_exists? && + attachments_association_exists? && + blobs_association_exists? && + eager_loading_scope_exists? + end + + private + + attr_reader :subject, :macro + + def reader_attribute_exists? + if subject.respond_to?(name) + true + else + @failure = "#{model_class.name} does not have a :#{name} method." + false + end + end + + def writer_attribute_exists? + if subject.respond_to?("#{name}=") + true + else + @failure = "#{model_class.name} does not have a :#{name}= method." + false + end + end + + def attachments_association_exists? + if attachments_association_matcher.matches?(subject) + true + else + @failure = attachments_association_matcher.failure_message + false + end + end + + def attachments_association_matcher + @_attachments_association_matcher ||= + AssociationMatcher.new( + :"has_#{macro}", + attachments_association_name, + ). + conditions(name: name). + class_name('ActiveStorage::Attachment'). + inverse_of(:record) + end + + def attachments_association_name + case macro + when :one then + "#{name}_attachment" + when :many then + "#{name}_attachments" + end + end + + def blobs_association_exists? + if blobs_association_matcher.matches?(subject) + true + else + @failure = blobs_association_matcher.failure_message + false + end + end + + def blobs_association_matcher + @_blobs_association_matcher ||= + AssociationMatcher.new( + :"has_#{macro}", + blobs_association_name, + ). + through(attachments_association_name). + class_name('ActiveStorage::Blob'). + source(:blob) + end + + def blobs_association_name + case macro + when :one then + "#{name}_blob" + when :many then + "#{name}_blobs" + end + end + + def eager_loading_scope_exists? + if model_class.respond_to?("with_attached_#{name}") + true + else + @failure = "#{model_class.name} does not have a " \ + ":with_attached_#{name} scope." + false + end + end + + def model_class + subject.class + end + end + end + end +end diff --git a/spec/support/unit/helpers/active_record_versions.rb b/spec/support/unit/helpers/active_record_versions.rb index cb3f35ac3..ad82aa957 100644 --- a/spec/support/unit/helpers/active_record_versions.rb +++ b/spec/support/unit/helpers/active_record_versions.rb @@ -47,6 +47,10 @@ def active_record_supports_expression_indexes? active_record_version >= 5 end + def active_record_supports_active_storage? + active_record_version >= 5.2 + end + def active_record_supports_validate_presence_on_active_storage? active_record_version >= '6.0.0.beta1' end diff --git a/spec/support/unit/helpers/rails_versions.rb b/spec/support/unit/helpers/rails_versions.rb index f6041a8c9..4e5c492d5 100644 --- a/spec/support/unit/helpers/rails_versions.rb +++ b/spec/support/unit/helpers/rails_versions.rb @@ -18,5 +18,9 @@ def rails_lt_5? def rails_5_x? rails_version =~ '~> 5.0' end + + def rails_gte_5_2? + rails_version >= 5.2 + end end end diff --git a/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb new file mode 100644 index 000000000..3ae5c22fa --- /dev/null +++ b/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb @@ -0,0 +1,272 @@ +require 'unit_spec_helper' + +describe Shoulda::Matchers::ActiveRecord::HaveAttachedMatcher, type: :model do + if active_record_supports_active_storage? + before do + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.bigint :byte_size, null: false + t.string :checksum, null: false + t.datetime :created_at, null: false + + t.index [:key], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + t.datetime :created_at, null: false + + t.index [:record_type, :record_id, :name, :blob_id], + name: 'index_active_storage_attachments_uniqueness', unique: true + + # The original rails migration has a foreign key. + # Since this messes up the clearing of the database, it's removed here. + # t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + describe 'have_one_attached' do + describe '#description' do + it 'returns the message with the name of the association' do + matcher = have_one_attached(:avatar) + expect(matcher.description). + to eq('have a has_one_attached called avatar') + end + end + + context 'when the attached exists on the model' do + it 'matches' do + record = record_having_one_attached(:avatar) + expect { have_one_attached(:avatar) }. + to match_against(record). + or_fail_with(<<-MESSAGE) +Did not expect User to have a has_one_attached called avatar, but it does. + MESSAGE + end + + context 'and the reader attribute does not exist' do + it 'matches' do + record = record_having_one_attached(:avatar, remove_reader: true) + expect { have_one_attached(:avatar) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_one_attached called avatar, but this could not be proved. + User does not have a :avatar method. + MESSAGE + end + end + + context 'and the writer attribute does not exist' do + it 'matches' do + record = record_having_one_attached(:avatar, remove_writer: true) + expect { have_one_attached(:avatar) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_one_attached called avatar, but this could not be proved. + User does not have a :avatar= method. + MESSAGE + end + end + + context 'and the attachments association does not exist' do + it 'matches' do + record = record_having_one_attached(:avatar, remove_attachments: true) + expect { have_one_attached(:avatar) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_one_attached called avatar, but this could not be proved. + Expected User to have a has_one association called avatar_attachment (no association called avatar_attachment) + MESSAGE + end + end + + context 'and the blobs association is invalid' do + it 'matches' do + record = record_having_one_attached(:avatar, invalidate_blobs: true) + expect { have_one_attached(:avatar) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_one_attached called avatar, but this could not be proved. + Expected User to have a has_one association called avatar_blob through avatar_attachment (avatar_blob should resolve to ActiveStorage::Blob for class_name) + MESSAGE + end + end + + context 'and the eager loading scope does not exist' do + it 'matches' do + record = record_having_one_attached(:avatar, remove_eager_loading_scope: true) + expect { have_one_attached(:avatar) }. + not_to match_against(record). + and_fail_with <<-MESSAGE +Expected User to have a has_one_attached called avatar, but this could not be proved. + User does not have a :with_attached_avatar scope. + MESSAGE + end + end + end + end + + describe 'have_many_attached' do + describe '#description' do + it 'returns the message with the name of the association' do + matcher = have_many_attached(:avatars) + expect(matcher.description). + to eq('have a has_many_attached called avatars') + end + end + + context 'when the attached exists on the model' do + it 'matches' do + record = record_having_many_attached(:avatars) + expect { have_many_attached(:avatars) }. + to match_against(record). + or_fail_with(<<-MESSAGE) +Did not expect User to have a has_many_attached called avatars, but it does. + MESSAGE + end + + context 'and the reader attribute does not exist' do + it 'matches' do + record = record_having_many_attached(:avatars, remove_reader: true) + expect { have_many_attached(:avatars) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_many_attached called avatars, but this could not be proved. + User does not have a :avatars method. + MESSAGE + end + end + + context 'and the writer attribute does not exist' do + it 'matches' do + record = record_having_many_attached(:avatars, remove_writer: true) + expect { have_many_attached(:avatars) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_many_attached called avatars, but this could not be proved. + User does not have a :avatars= method. + MESSAGE + end + end + + context 'and the attachments association does not exist' do + it 'matches' do + record = record_having_many_attached(:avatars, remove_attachments: true) + expect { have_many_attached(:avatars) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_many_attached called avatars, but this could not be proved. + Expected User to have a has_many association called avatars_attachments (no association called avatars_attachments) + MESSAGE + end + end + + context 'and the blobs association is invalid' do + it 'matches' do + record = record_having_many_attached(:avatars, invalidate_blobs: true) + expect { have_many_attached(:avatars) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_many_attached called avatars, but this could not be proved. + Expected User to have a has_many association called avatars_blobs through avatars_attachments (avatars_blobs should resolve to ActiveStorage::Blob for class_name) + MESSAGE + end + end + + context 'and the eager loading scope does not exist' do + it 'matches' do + record = record_having_many_attached(:avatars, remove_eager_loading_scope: true) + expect { have_many_attached(:avatars) }. + not_to match_against(record). + and_fail_with(<<-MESSAGE) +Expected User to have a has_many_attached called avatars, but this could not be proved. + User does not have a :with_attached_avatars scope. + MESSAGE + end + end + end + end + end +end + +def record_having_one_attached( + attached_name, + model_name: 'User', + remove_reader: false, + remove_writer: false, + remove_attachments: false, + invalidate_blobs: false, + remove_eager_loading_scope: false +) + model = define_model(model_name) do + has_one_attached attached_name + + if remove_reader + undef_method attached_name + end + + if remove_writer + undef_method "#{attached_name}=" + end + + if remove_attachments + reflections.delete("#{attached_name}_attachment") + end + + if invalidate_blobs + reflections["#{attached_name}_blob"].options[:class_name] = 'User' + end + + if remove_eager_loading_scope + instance_eval <<-CODE, __FILE__, __LINE__ + 1 +undef with_attached_#{attached_name} + CODE + end + end + + model.new +end + +def record_having_many_attached( + attached_name, + model_name: 'User', + remove_reader: false, + remove_writer: false, + remove_attachments: false, + invalidate_blobs: false, + remove_eager_loading_scope: false +) + model = define_model(model_name) do + has_many_attached attached_name + + if remove_reader + undef_method attached_name + end + + if remove_writer + undef_method "#{attached_name}=" + end + + if remove_attachments + reflections.delete("#{attached_name}_attachments") + end + + if invalidate_blobs + reflections["#{attached_name}_blobs"].options[:class_name] = 'User' + end + + if remove_eager_loading_scope + instance_eval <<-CODE, __FILE__, __LINE__ + 1 +undef with_attached_#{attached_name} + CODE + end + end + + model.new +end