about summary refs log tree commit diff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/statuses_cleanup_controller_spec.rb27
-rw-r--r--spec/fabricators/account_statuses_cleanup_policy_fabricator.rb3
-rw-r--r--spec/models/account_statuses_cleanup_policy_spec.rb546
-rw-r--r--spec/services/account_statuses_cleanup_service_spec.rb101
-rw-r--r--spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb127
5 files changed, 804 insertions, 0 deletions
diff --git a/spec/controllers/statuses_cleanup_controller_spec.rb b/spec/controllers/statuses_cleanup_controller_spec.rb
new file mode 100644
index 000000000..924709260
--- /dev/null
+++ b/spec/controllers/statuses_cleanup_controller_spec.rb
@@ -0,0 +1,27 @@
+require 'rails_helper'
+
+RSpec.describe StatusesCleanupController, type: :controller do
+  render_views
+
+  before do
+    @user = Fabricate(:user)
+    sign_in @user, scope: :user
+  end
+
+  describe "GET #show" do
+    it "returns http success" do
+      get :show
+      expect(response).to have_http_status(200)
+    end
+  end
+
+  describe 'PUT #update' do
+    it 'updates the account status cleanup policy' do
+      put :update, params: { account_statuses_cleanup_policy: { enabled: true, min_status_age: 2.weeks.seconds, keep_direct: false, keep_polls: true } }
+      expect(response).to redirect_to(statuses_cleanup_path)
+      expect(@user.account.statuses_cleanup_policy.enabled).to eq true
+      expect(@user.account.statuses_cleanup_policy.keep_direct).to eq false
+      expect(@user.account.statuses_cleanup_policy.keep_polls).to eq true
+    end
+  end
+end
diff --git a/spec/fabricators/account_statuses_cleanup_policy_fabricator.rb b/spec/fabricators/account_statuses_cleanup_policy_fabricator.rb
new file mode 100644
index 000000000..29cf1d133
--- /dev/null
+++ b/spec/fabricators/account_statuses_cleanup_policy_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:account_statuses_cleanup_policy) do
+  account
+end
diff --git a/spec/models/account_statuses_cleanup_policy_spec.rb b/spec/models/account_statuses_cleanup_policy_spec.rb
new file mode 100644
index 000000000..63e9c5d20
--- /dev/null
+++ b/spec/models/account_statuses_cleanup_policy_spec.rb
@@ -0,0 +1,546 @@
+require 'rails_helper'
+
+RSpec.describe AccountStatusesCleanupPolicy, type: :model do
+  let(:account) { Fabricate(:account, username: 'alice', domain: nil) }
+
+  describe 'validation' do
+    it 'disallow remote accounts' do
+      account.update(domain: 'example.com')
+      account_statuses_cleanup_policy = Fabricate.build(:account_statuses_cleanup_policy, account: account)
+      account_statuses_cleanup_policy.valid?
+      expect(account_statuses_cleanup_policy).to model_have_error_on_field(:account)
+    end
+  end
+
+  describe 'save hooks' do
+    context 'when widening a policy' do
+      let!(:account_statuses_cleanup_policy) do
+        Fabricate(:account_statuses_cleanup_policy,
+          account: account,
+          keep_direct: true,
+          keep_pinned: true,
+          keep_polls: true,
+          keep_media: true,
+          keep_self_fav: true,
+          keep_self_bookmark: true,
+          min_favs: 1,
+          min_reblogs: 1
+        )
+      end
+
+      before do
+        account_statuses_cleanup_policy.record_last_inspected(42)
+      end
+
+      it 'invalidates last_inspected when widened because of keep_direct' do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of keep_pinned' do
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of keep_polls' do
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of keep_media' do
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of keep_self_fav' do
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of keep_self_bookmark' do
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of higher min_favs' do
+        account_statuses_cleanup_policy.min_favs = 5
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of disabled min_favs' do
+        account_statuses_cleanup_policy.min_favs = nil
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of higher min_reblogs' do
+        account_statuses_cleanup_policy.min_reblogs = 5
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+
+      it 'invalidates last_inspected when widened because of disable min_reblogs' do
+        account_statuses_cleanup_policy.min_reblogs = nil
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to be nil
+      end
+    end
+
+    context 'when narrowing a policy' do
+      let!(:account_statuses_cleanup_policy) do
+        Fabricate(:account_statuses_cleanup_policy,
+          account: account,
+          keep_direct: false,
+          keep_pinned: false,
+          keep_polls: false,
+          keep_media: false,
+          keep_self_fav: false,
+          keep_self_bookmark: false,
+          min_favs: nil,
+          min_reblogs: nil
+        )
+      end
+
+      it 'does not unnecessarily invalidate last_inspected' do
+        account_statuses_cleanup_policy.record_last_inspected(42)
+        account_statuses_cleanup_policy.keep_direct = true
+        account_statuses_cleanup_policy.keep_pinned = true
+        account_statuses_cleanup_policy.keep_polls = true
+        account_statuses_cleanup_policy.keep_media = true
+        account_statuses_cleanup_policy.keep_self_fav = true
+        account_statuses_cleanup_policy.keep_self_bookmark = true
+        account_statuses_cleanup_policy.min_favs = 5
+        account_statuses_cleanup_policy.min_reblogs = 5
+        account_statuses_cleanup_policy.save
+        expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+      end
+    end
+  end
+
+  describe '#record_last_inspected' do
+    let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) }
+
+    it 'records the given id' do
+      account_statuses_cleanup_policy.record_last_inspected(42)
+      expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+    end
+  end
+
+  describe '#invalidate_last_inspected' do
+    let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) }
+    let(:status) { Fabricate(:status, id: 10, account: account) }
+    subject { account_statuses_cleanup_policy.invalidate_last_inspected(status, action) }
+
+    before do
+      account_statuses_cleanup_policy.record_last_inspected(42)
+    end
+
+    context 'when the action is :unbookmark' do
+      let(:action) { :unbookmark }
+
+      context 'when the policy is not to keep self-bookmarked toots' do
+        before do
+          account_statuses_cleanup_policy.keep_self_bookmark = false
+        end
+
+        it 'does not change the recorded id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+        end
+      end
+
+      context 'when the policy is to keep self-bookmarked toots' do
+        before do
+          account_statuses_cleanup_policy.keep_self_bookmark = true
+        end
+
+        it 'records the older id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 10
+        end
+      end
+    end
+
+    context 'when the action is :unfav' do
+      let(:action) { :unfav }
+
+      context 'when the policy is not to keep self-favourited toots' do
+        before do
+          account_statuses_cleanup_policy.keep_self_fav = false
+        end
+
+        it 'does not change the recorded id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+        end
+      end
+
+      context 'when the policy is to keep self-favourited toots' do
+        before do
+          account_statuses_cleanup_policy.keep_self_fav = true
+        end
+
+        it 'records the older id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 10
+        end
+      end
+    end
+
+    context 'when the action is :unpin' do
+      let(:action) { :unpin }
+
+      context 'when the policy is not to keep pinned toots' do
+        before do
+          account_statuses_cleanup_policy.keep_pinned = false
+        end
+
+        it 'does not change the recorded id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+        end
+      end
+
+      context 'when the policy is to keep pinned toots' do
+        before do
+          account_statuses_cleanup_policy.keep_pinned = true
+        end
+
+        it 'records the older id' do
+          subject
+          expect(account_statuses_cleanup_policy.last_inspected).to eq 10
+        end
+      end
+    end
+
+    context 'when the status is more recent than the recorded inspected id' do
+      let(:action) { :unfav }
+      let(:status) { Fabricate(:status, account: account) }
+
+      it 'does not change the recorded id' do
+        subject
+        expect(account_statuses_cleanup_policy.last_inspected).to eq 42
+      end
+    end
+  end
+
+  describe '#compute_cutoff_id' do
+    let!(:unrelated_status)  { Fabricate(:status, created_at: 3.years.ago) }
+    let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) }
+
+    subject { account_statuses_cleanup_policy.compute_cutoff_id }
+
+    context 'when the account has posted multiple toots' do
+      let!(:very_old_status)   { Fabricate(:status, created_at: 3.years.ago, account: account) }
+      let!(:old_status)        { Fabricate(:status, created_at: 3.weeks.ago, account: account) }
+      let!(:recent_status)     { Fabricate(:status, created_at: 2.days.ago, account: account) }
+
+      it 'returns the most recent id that is still below policy age' do
+        expect(subject).to eq old_status.id
+      end
+    end
+
+    context 'when the account has not posted anything' do
+      it 'returns nil' do
+        expect(subject).to be_nil
+      end
+    end
+  end
+
+  describe '#statuses_to_delete' do
+    let!(:unrelated_status)  { Fabricate(:status, created_at: 3.years.ago) }
+    let!(:very_old_status)   { Fabricate(:status, created_at: 3.years.ago, account: account) }
+    let!(:pinned_status)     { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:direct_message)    { Fabricate(:status, created_at: 1.year.ago, account: account, visibility: :direct) }
+    let!(:self_faved)        { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:self_bookmarked)   { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:status_with_poll)  { Fabricate(:status, created_at: 1.year.ago, account: account, poll_attributes: { account: account, voters_count: 0, options: ['a', 'b'], expires_in: 2.days }) }
+    let!(:status_with_media) { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:faved4)            { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:faved5)            { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:reblogged4)        { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:reblogged5)        { Fabricate(:status, created_at: 1.year.ago, account: account) }
+    let!(:recent_status)     { Fabricate(:status, created_at: 2.days.ago, account: account) }
+
+    let!(:media_attachment)  { Fabricate(:media_attachment, account: account, status: status_with_media) }
+    let!(:status_pin)        { Fabricate(:status_pin, account: account, status: pinned_status) }
+    let!(:favourite)         { Fabricate(:favourite, account: account, status: self_faved) }
+    let!(:bookmark)          { Fabricate(:bookmark, account: account, status: self_bookmarked) }
+
+    let(:account_statuses_cleanup_policy) { Fabricate(:account_statuses_cleanup_policy, account: account) }
+
+    subject { account_statuses_cleanup_policy.statuses_to_delete }
+
+    before do
+      4.times { faved4.increment_count!(:favourites_count) }
+      5.times { faved5.increment_count!(:favourites_count) }
+      4.times { reblogged4.increment_count!(:reblogs_count) }
+      5.times { reblogged5.increment_count!(:reblogs_count) }
+    end
+
+    context 'when passed a max_id' do
+      let!(:old_status)               { Fabricate(:status, created_at: 1.year.ago, account: account) }
+      let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) }
+
+      subject { account_statuses_cleanup_policy.statuses_to_delete(50, old_status.id).pluck(:id) }
+
+      it 'returns statuses including max_id' do
+        expect(subject).to include(old_status.id)
+      end
+
+      it 'returns statuses including older than max_id' do
+        expect(subject).to include(very_old_status.id)
+      end
+
+      it 'does not return statuses newer than max_id' do
+        expect(subject).to_not include(slightly_less_old_status.id)
+      end
+    end
+
+    context 'when passed a min_id' do
+      let!(:old_status)               { Fabricate(:status, created_at: 1.year.ago, account: account) }
+      let!(:slightly_less_old_status) { Fabricate(:status, created_at: 6.months.ago, account: account) }
+
+      subject { account_statuses_cleanup_policy.statuses_to_delete(50, recent_status.id, old_status.id).pluck(:id) }
+
+      it 'returns statuses including min_id' do
+        expect(subject).to include(old_status.id)
+      end
+
+      it 'returns statuses including newer than max_id' do
+        expect(subject).to include(slightly_less_old_status.id)
+      end
+
+      it 'does not return statuses older than min_id' do
+        expect(subject).to_not include(very_old_status.id)
+      end
+    end
+
+    context 'when passed a low limit' do
+      it 'only returns the limited number of items' do
+        expect(account_statuses_cleanup_policy.statuses_to_delete(1).count).to eq 1
+      end
+    end
+
+    context 'when policy is set to keep statuses more recent than 2 years' do
+      before do
+        account_statuses_cleanup_policy.min_status_age = 2.years.seconds
+      end
+
+      it 'does not return unrelated old status' do
+        expect(subject.pluck(:id)).to_not include(unrelated_status.id)
+      end
+
+      it 'returns only oldest status for deletion' do
+        expect(subject.pluck(:id)).to eq [very_old_status.id]
+      end
+    end
+
+    context 'when policy is set to keep DMs and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = true
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the old direct message for deletion' do
+        expect(subject.pluck(:id)).to_not include(direct_message.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep self-bookmarked toots and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = true
+      end
+
+      it 'does not return the old self-bookmarked message for deletion' do
+        expect(subject.pluck(:id)).to_not include(self_bookmarked.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, status_with_poll.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep self-faved toots and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = true
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the old self-bookmarked message for deletion' do
+        expect(subject.pluck(:id)).to_not include(self_faved.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep toots with media and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = true
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the old message with media for deletion' do
+        expect(subject.pluck(:id)).to_not include(status_with_media.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep toots with polls and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = true
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the old poll message for deletion' do
+        expect(subject.pluck(:id)).to_not include(status_with_poll.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep pinned toots and reject everything else' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = true
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the old pinned message for deletion' do
+        expect(subject.pluck(:id)).to_not include(pinned_status.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is to not keep any special messages' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = false
+        account_statuses_cleanup_policy.keep_pinned = false
+        account_statuses_cleanup_policy.keep_polls = false
+        account_statuses_cleanup_policy.keep_media = false
+        account_statuses_cleanup_policy.keep_self_fav = false
+        account_statuses_cleanup_policy.keep_self_bookmark = false
+      end
+
+      it 'does not return the recent toot' do
+        expect(subject.pluck(:id)).to_not include(recent_status.id)
+      end
+
+      it 'does not return the unrelated toot' do
+        expect(subject.pluck(:id)).to_not include(unrelated_status.id)
+      end
+
+      it 'returns every other old status for deletion' do
+        expect(subject.pluck(:id)).to include(direct_message.id, very_old_status.id, pinned_status.id, self_faved.id, self_bookmarked.id, status_with_poll.id, status_with_media.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id)
+      end
+    end
+
+    context 'when policy is set to keep every category of toots' do
+      before do
+        account_statuses_cleanup_policy.keep_direct = true
+        account_statuses_cleanup_policy.keep_pinned = true
+        account_statuses_cleanup_policy.keep_polls = true
+        account_statuses_cleanup_policy.keep_media = true
+        account_statuses_cleanup_policy.keep_self_fav = true
+        account_statuses_cleanup_policy.keep_self_bookmark = true
+      end
+
+      it 'does not return unrelated old status' do
+        expect(subject.pluck(:id)).to_not include(unrelated_status.id)
+      end
+
+      it 'returns only normal statuses for deletion' do
+        expect(subject.pluck(:id).sort).to eq [very_old_status.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id].sort
+      end
+    end
+
+    context 'when policy is to keep statuses with more than 4 boosts' do
+      before do
+        account_statuses_cleanup_policy.min_reblogs = 4
+      end
+
+      it 'does not return the recent toot' do
+        expect(subject.pluck(:id)).to_not include(recent_status.id)
+      end
+
+      it 'does not return the toot reblogged 5 times' do
+        expect(subject.pluck(:id)).to_not include(reblogged5.id)
+      end
+
+      it 'does not return the unrelated toot' do
+        expect(subject.pluck(:id)).to_not include(unrelated_status.id)
+      end
+
+      it 'returns old statuses not reblogged as much' do
+        expect(subject.pluck(:id)).to include(very_old_status.id, faved4.id, faved5.id, reblogged4.id)
+      end
+    end
+
+    context 'when policy is to keep statuses with more than 4 favs' do
+      before do
+        account_statuses_cleanup_policy.min_favs = 4
+      end
+
+      it 'does not return the recent toot' do
+        expect(subject.pluck(:id)).to_not include(recent_status.id)
+      end
+
+      it 'does not return the toot faved 5 times' do
+        expect(subject.pluck(:id)).to_not include(faved5.id)
+      end
+
+      it 'does not return the unrelated toot' do
+        expect(subject.pluck(:id)).to_not include(unrelated_status.id)
+      end
+
+      it 'returns old statuses not faved as much' do
+        expect(subject.pluck(:id)).to include(very_old_status.id, faved4.id, reblogged4.id, reblogged5.id)
+      end
+    end
+  end
+end
diff --git a/spec/services/account_statuses_cleanup_service_spec.rb b/spec/services/account_statuses_cleanup_service_spec.rb
new file mode 100644
index 000000000..257655c41
--- /dev/null
+++ b/spec/services/account_statuses_cleanup_service_spec.rb
@@ -0,0 +1,101 @@
+require 'rails_helper'
+
+describe AccountStatusesCleanupService, type: :service do
+  let(:account)           { Fabricate(:account, username: 'alice', domain: nil) }
+  let(:account_policy)    { Fabricate(:account_statuses_cleanup_policy, account: account) }
+  let!(:unrelated_status) { Fabricate(:status, created_at: 3.years.ago) }
+
+  describe '#call' do
+    context 'when the account has not posted anything' do
+      it 'returns 0 deleted toots' do
+        expect(subject.call(account_policy)).to eq 0
+      end
+    end
+
+    context 'when the account has posted several old statuses' do
+      let!(:very_old_status)    { Fabricate(:status, created_at: 3.years.ago, account: account) }
+      let!(:old_status)         { Fabricate(:status, created_at: 1.year.ago, account: account) }
+      let!(:another_old_status) { Fabricate(:status, created_at: 1.year.ago, account: account) }
+      let!(:recent_status)      { Fabricate(:status, created_at: 1.day.ago, account: account) }
+
+      context 'given a budget of 1' do
+        it 'reports 1 deleted toot' do
+          expect(subject.call(account_policy, 1)).to eq 1
+        end
+      end
+
+      context 'given a normal budget of 10' do
+        it 'reports 3 deleted statuses' do
+          expect(subject.call(account_policy, 10)).to eq 3
+        end
+
+        it 'records the last deleted id' do
+          subject.call(account_policy, 10)
+          expect(account_policy.last_inspected).to eq [old_status.id, another_old_status.id].max
+        end
+
+        it 'actually deletes the statuses' do
+          subject.call(account_policy, 10)
+          expect(Status.find_by(id: [very_old_status.id, old_status.id, another_old_status.id])).to be_nil
+        end
+      end
+
+      context 'when called repeatedly with a budget of 2' do
+        it 'reports 2 then 1 deleted statuses' do
+         expect(subject.call(account_policy, 2)).to eq 2
+         expect(subject.call(account_policy, 2)).to eq 1
+        end
+
+        it 'actually deletes the statuses in the expected order' do
+          subject.call(account_policy, 2)
+          expect(Status.find_by(id: very_old_status.id)).to be_nil
+          subject.call(account_policy, 2)
+          expect(Status.find_by(id: [very_old_status.id, old_status.id, another_old_status.id])).to be_nil
+        end
+      end
+
+      context 'when a self-faved toot is unfaved' do
+        let!(:self_faved) { Fabricate(:status, created_at: 6.months.ago, account: account) }
+        let!(:favourite)  { Fabricate(:favourite, account: account, status: self_faved) }
+
+        it 'deletes it once unfaved' do
+          expect(subject.call(account_policy, 20)).to eq 3
+          expect(Status.find_by(id: self_faved.id)).to_not be_nil
+          expect(subject.call(account_policy, 20)).to eq 0
+          favourite.destroy!
+          expect(subject.call(account_policy, 20)).to eq 1
+          expect(Status.find_by(id: self_faved.id)).to be_nil
+        end
+      end
+
+      context 'when there are more un-deletable old toots than the early search cutoff' do
+        before do
+          stub_const 'AccountStatusesCleanupPolicy::EARLY_SEARCH_CUTOFF', 5
+          # Old statuses that should be cut-off
+          10.times do
+            Fabricate(:status, created_at: 4.years.ago, visibility: :direct, account: account)
+          end
+          # New statuses that prevent cut-off id to reach the last status
+          10.times do
+            Fabricate(:status, created_at: 4.seconds.ago, visibility: :direct, account: account)
+          end
+        end
+
+        it 'reports 0 deleted statuses then 0 then 3 then 0 again' do
+          expect(subject.call(account_policy, 10)).to eq 0
+          expect(subject.call(account_policy, 10)).to eq 0
+          expect(subject.call(account_policy, 10)).to eq 3
+          expect(subject.call(account_policy, 10)).to eq 0
+        end
+
+        it 'never causes the recorded id to get higher than oldest deletable toot' do
+          subject.call(account_policy, 10)
+          subject.call(account_policy, 10)
+          subject.call(account_policy, 10)
+          subject.call(account_policy, 10)
+          expect(account_policy.last_inspected).to be < Mastodon::Snowflake.id_at(account_policy.min_status_age.seconds.ago, with_random: false)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
new file mode 100644
index 000000000..8f20725c8
--- /dev/null
+++ b/spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb
@@ -0,0 +1,127 @@
+require 'rails_helper'
+
+describe Scheduler::AccountsStatusesCleanupScheduler do
+  subject { described_class.new }
+
+  let!(:account1)  { Fabricate(:account, domain: nil) }
+  let!(:account2)  { Fabricate(:account, domain: nil) }
+  let!(:account3)  { Fabricate(:account, domain: nil) }
+  let!(:account4)  { Fabricate(:account, domain: nil) }
+  let!(:remote)    { Fabricate(:account) }
+
+  let!(:policy1)   { Fabricate(:account_statuses_cleanup_policy, account: account1) }
+  let!(:policy2)   { Fabricate(:account_statuses_cleanup_policy, account: account3) }
+  let!(:policy3)   { Fabricate(:account_statuses_cleanup_policy, account: account4, enabled: false) }
+
+  let(:queue_size)       { 0 }
+  let(:queue_latency)    { 0 }
+  let(:process_set_stub) do
+    [
+      {
+        'concurrency' => 2,
+        'queues' => ['push', 'default'],
+      },
+    ]
+  end
+  let(:retry_size) { 0 }
+
+  before do
+    queue_stub = double
+    allow(queue_stub).to receive(:size).and_return(queue_size)
+    allow(queue_stub).to receive(:latency).and_return(queue_latency)
+    allow(Sidekiq::Queue).to receive(:new).and_return(queue_stub)
+    allow(Sidekiq::ProcessSet).to receive(:new).and_return(process_set_stub)
+
+    sidekiq_stats_stub = double
+    allow(sidekiq_stats_stub).to receive(:retry_size).and_return(retry_size)
+    allow(Sidekiq::Stats).to receive(:new).and_return(sidekiq_stats_stub)
+
+    # Create a bunch of old statuses
+    10.times do
+      Fabricate(:status, account: account1, created_at: 3.years.ago)
+      Fabricate(:status, account: account2, created_at: 3.years.ago)
+      Fabricate(:status, account: account3, created_at: 3.years.ago)
+      Fabricate(:status, account: account4, created_at: 3.years.ago)
+      Fabricate(:status, account: remote, created_at: 3.years.ago)
+    end
+
+    # Create a bunch of newer statuses
+    5.times do
+      Fabricate(:status, account: account1, created_at: 3.minutes.ago)
+      Fabricate(:status, account: account2, created_at: 3.minutes.ago)
+      Fabricate(:status, account: account3, created_at: 3.minutes.ago)
+      Fabricate(:status, account: account4, created_at: 3.minutes.ago)
+      Fabricate(:status, account: remote, created_at: 3.minutes.ago)
+    end
+  end
+
+  describe '#under_load?' do
+    context 'when nothing is queued' do
+      it 'returns false' do
+        expect(subject.under_load?).to be false
+      end
+    end
+
+    context 'when numerous jobs are queued' do
+      let(:queue_size)    { 5 }
+      let(:queue_latency) { 120 }
+
+      it 'returns true' do
+        expect(subject.under_load?).to be true
+      end
+    end
+
+    context 'when there is a huge amount of jobs to retry' do
+      let(:retry_size) { 1_000_000 }
+
+      it 'returns true' do
+        expect(subject.under_load?).to be true
+      end
+    end
+  end
+
+  describe '#get_budget' do
+    context 'on a single thread' do
+      let(:process_set_stub) { [ { 'concurrency' => 1, 'queues' => ['push', 'default'] } ] }
+
+      it 'returns a low value' do
+        expect(subject.compute_budget).to be < 10
+      end
+    end
+
+    context 'on a lot of threads' do
+      let(:process_set_stub) do
+        [
+          { 'concurrency' => 2, 'queues' => ['push', 'default'] },
+          { 'concurrency' => 2, 'queues' => ['push'] },
+          { 'concurrency' => 2, 'queues' => ['push'] },
+          { 'concurrency' => 2, 'queues' => ['push'] },
+        ]
+      end
+
+      it 'returns a larger value' do
+        expect(subject.compute_budget).to be > 10
+      end
+    end
+  end
+
+  describe '#perform' do
+    context 'when the budget is lower than the number of toots to delete' do
+      it 'deletes as many statuses as the given budget' do
+        expect { subject.perform }.to change { Status.count }.by(-subject.compute_budget)
+      end
+
+      it 'does not delete from accounts with no cleanup policy' do
+        expect { subject.perform }.to_not change { account2.statuses.count }
+      end
+
+      it 'does not delete from accounts with disabled cleanup policies' do
+        expect { subject.perform }.to_not change { account4.statuses.count }
+      end
+
+      it 'eventually deletes every deletable toot' do
+        expect { subject.perform; subject.perform; subject.perform; subject.perform }.to change { Status.count }.by(-20)
+      end
+    end
+  end
+end