diff --git a/spec/models/acts_as_paranoid_spec.rb b/spec/models/acts_as_paranoid_spec.rb new file mode 100644 index 0000000000..8af89865d9 --- /dev/null +++ b/spec/models/acts_as_paranoid_spec.rb @@ -0,0 +1,34 @@ +require "rails_helper" + +RSpec.describe "acts_as_paranoid" do + let(:currently_probably_buggy_classes_ignored) do + %w[] + end + let(:allows_multiple_deleted) { "(deleted_at IS NULL)" } + + it "checks that all activerecord models using acts_as_paranoid have the deleted exclusions on unique indexes" do + errors = [] + found_ignored_error_indexes = [] + Zeitwerk::Loader.eager_load_all + expect(ApplicationRecord.descendants.count).to be >= 54 # make sure we are actually testing all model classes + ApplicationRecord.descendants.each do |clazz| + next if clazz.abstract_class? + next unless clazz.paranoid? + unique_indexes = ApplicationRecord.connection_pool.with_connection do |connection| + connection.indexes(clazz.table_name).select(&:unique) + end + unique_indexes.each do |idx| + next if idx.columns == ["external_id"] # it is ok for external_id to be unique + if currently_probably_buggy_classes_ignored.include?(idx.name) + found_ignored_error_indexes << idx.name + next + end + unless idx.where&.include?(allows_multiple_deleted) + errors << "#{idx.name} on #{clazz} uses acts_as_paranoid but has a unique index without #{allows_multiple_deleted} but it does have: #{idx.where}" + end + end + end + expect(errors).to be_empty + expect(found_ignored_error_indexes).to match_array(currently_probably_buggy_classes_ignored) + end +end diff --git a/spec/models/soft_deleted_model_shared_example_coverage_spec.rb b/spec/models/soft_deleted_model_shared_example_coverage_spec.rb new file mode 100644 index 0000000000..9d865c8da8 --- /dev/null +++ b/spec/models/soft_deleted_model_shared_example_coverage_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +RSpec.describe "soft-deleted model shared example coverage" do + let(:skip_classes) do + %w[] + end + + let(:todo_currently_missing_specs) do + %w[ + ContactTopicAnswer + CaseContact + ] + end + + it "checks that all acts_as_paranoid models have specs that include the soft-deleted model shared example" do + missing = [] + Zeitwerk::Loader.eager_load_all + + ApplicationRecord.descendants.each do |clazz| + next if clazz.abstract_class? + next unless clazz.paranoid? + next if skip_classes.include?(clazz.name) + next if todo_currently_missing_specs.include?(clazz.name) + + source_file = Object.const_source_location(clazz.name)&.first + next unless source_file + + spec_file = source_file + .sub(%r{/app/models/}, "/spec/models/") + .sub(/\.rb$/, "_spec.rb") + + unless File.exist?(spec_file) + missing << "#{clazz.name}: spec file not found (expected #{spec_file})" + next + end + + contents = File.read(spec_file) + unless contents.include?('"a soft-deleted model"') + missing << clazz.name.to_s + end + end + + expect(missing).to be_empty, "The following paranoid models are missing the shared example:\n#{missing.join("\n")}" + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb index 620f3930c8..9a1b5658ce 100644 --- a/spec/support/factory_bot.rb +++ b/spec/support/factory_bot.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true +def described_class_factory + described_class.name.gsub("::", "").underscore +end + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods diff --git a/spec/support/shared_examples/soft_deleted_model.rb b/spec/support/shared_examples/soft_deleted_model.rb new file mode 100644 index 0000000000..4cef243f86 --- /dev/null +++ b/spec/support/shared_examples/soft_deleted_model.rb @@ -0,0 +1,34 @@ +RSpec.shared_examples_for "a soft-deleted model" do |skip_ignores_deleted_records_in_validations_check: false, skip_deleted_at_index_check: false| + # for usage with acts_as_paranoid models + + it { is_expected.to have_db_column(:deleted_at) } + + unless skip_deleted_at_index_check + it { is_expected.to have_db_index(:deleted_at) } + end + + it "cannot be found, by default" do + model ||= create(described_class_factory) + model.destroy! + expect(described_class.find_by(id: model.id)).to be_nil + end + + it "returned when unscoped" do + model ||= create(described_class_factory) + model.destroy! + expect(described_class.unscoped.find_by(id: model.id)).to be_present + end + + context "uniqueness" do + it "ignores deleted records in validations" do + unless skip_ignores_deleted_records_in_validations_check + obj = create(described_class_factory) + new_obj = obj.dup + expect(new_obj).not_to be_valid + obj.destroy! + expect(new_obj).to be_valid + expect { new_obj.save! }.not_to raise_exception + end + end + end +end