Skip to content

Commit 88eb623

Browse files
compwronclaude
andauthored
Add acts_as_paranoid test coverage (#6862)
* Add acts_as_paranoid test coverage and soft-deleted model shared example Add specs to ensure all paranoid models have proper unique index conditions and include the soft-deleted model shared example. Add shared example for reusable soft-delete behavior testing and described_class_factory helper. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix lint --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 77a8951 commit 88eb623

File tree

4 files changed

+117
-0
lines changed

4 files changed

+117
-0
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "acts_as_paranoid" do
4+
let(:currently_probably_buggy_classes_ignored) do
5+
%w[]
6+
end
7+
let(:allows_multiple_deleted) { "(deleted_at IS NULL)" }
8+
9+
it "checks that all activerecord models using acts_as_paranoid have the deleted exclusions on unique indexes" do
10+
errors = []
11+
found_ignored_error_indexes = []
12+
Zeitwerk::Loader.eager_load_all
13+
expect(ApplicationRecord.descendants.count).to be >= 54 # make sure we are actually testing all model classes
14+
ApplicationRecord.descendants.each do |clazz|
15+
next if clazz.abstract_class?
16+
next unless clazz.paranoid?
17+
unique_indexes = ApplicationRecord.connection_pool.with_connection do |connection|
18+
connection.indexes(clazz.table_name).select(&:unique)
19+
end
20+
unique_indexes.each do |idx|
21+
next if idx.columns == ["external_id"] # it is ok for external_id to be unique
22+
if currently_probably_buggy_classes_ignored.include?(idx.name)
23+
found_ignored_error_indexes << idx.name
24+
next
25+
end
26+
unless idx.where&.include?(allows_multiple_deleted)
27+
errors << "#{idx.name} on #{clazz} uses acts_as_paranoid but has a unique index without #{allows_multiple_deleted} but it does have: #{idx.where}"
28+
end
29+
end
30+
end
31+
expect(errors).to be_empty
32+
expect(found_ignored_error_indexes).to match_array(currently_probably_buggy_classes_ignored)
33+
end
34+
end
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "soft-deleted model shared example coverage" do
4+
let(:skip_classes) do
5+
%w[]
6+
end
7+
8+
let(:todo_currently_missing_specs) do
9+
%w[
10+
ContactTopicAnswer
11+
CaseContact
12+
]
13+
end
14+
15+
it "checks that all acts_as_paranoid models have specs that include the soft-deleted model shared example" do
16+
missing = []
17+
Zeitwerk::Loader.eager_load_all
18+
19+
ApplicationRecord.descendants.each do |clazz|
20+
next if clazz.abstract_class?
21+
next unless clazz.paranoid?
22+
next if skip_classes.include?(clazz.name)
23+
next if todo_currently_missing_specs.include?(clazz.name)
24+
25+
source_file = Object.const_source_location(clazz.name)&.first
26+
next unless source_file
27+
28+
spec_file = source_file
29+
.sub(%r{/app/models/}, "/spec/models/")
30+
.sub(/\.rb$/, "_spec.rb")
31+
32+
unless File.exist?(spec_file)
33+
missing << "#{clazz.name}: spec file not found (expected #{spec_file})"
34+
next
35+
end
36+
37+
contents = File.read(spec_file)
38+
unless contents.include?('"a soft-deleted model"')
39+
missing << clazz.name.to_s
40+
end
41+
end
42+
43+
expect(missing).to be_empty, "The following paranoid models are missing the shared example:\n#{missing.join("\n")}"
44+
end
45+
end

spec/support/factory_bot.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# frozen_string_literal: true
22

3+
def described_class_factory
4+
described_class.name.gsub("::", "").underscore
5+
end
6+
37
RSpec.configure do |config|
48
config.include FactoryBot::Syntax::Methods
59

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
RSpec.shared_examples_for "a soft-deleted model" do |skip_ignores_deleted_records_in_validations_check: false, skip_deleted_at_index_check: false|
2+
# for usage with acts_as_paranoid models
3+
4+
it { is_expected.to have_db_column(:deleted_at) }
5+
6+
unless skip_deleted_at_index_check
7+
it { is_expected.to have_db_index(:deleted_at) }
8+
end
9+
10+
it "cannot be found, by default" do
11+
model ||= create(described_class_factory)
12+
model.destroy!
13+
expect(described_class.find_by(id: model.id)).to be_nil
14+
end
15+
16+
it "returned when unscoped" do
17+
model ||= create(described_class_factory)
18+
model.destroy!
19+
expect(described_class.unscoped.find_by(id: model.id)).to be_present
20+
end
21+
22+
context "uniqueness" do
23+
it "ignores deleted records in validations" do
24+
unless skip_ignores_deleted_records_in_validations_check
25+
obj = create(described_class_factory)
26+
new_obj = obj.dup
27+
expect(new_obj).not_to be_valid
28+
obj.destroy!
29+
expect(new_obj).to be_valid
30+
expect { new_obj.save! }.not_to raise_exception
31+
end
32+
end
33+
end
34+
end

0 commit comments

Comments
 (0)