From d57f8e6567cc6efa8819f94bb4d9d820387b9a6a Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 9 Feb 2026 20:18:17 -0800 Subject: [PATCH 1/7] prune statuses without a local reply beneath them --- lib/mastodon/cli/statuses.rb | 23 +++++++++++++++++++- spec/lib/mastodon/cli/statuses_spec.rb | 29 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index df0fcf0fbb0..84b8ecd9f40 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -52,10 +52,14 @@ module Mastodon::CLI # Skip accounts followed by local accounts clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed] + # keep direct replies to local statuses + keep_direct_replies_sql = 'AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.in_reply_to_id = statuses1.id AND (statuses1.uri IS NULL OR statuses1.local))' + ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL', [max_id]) INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) - AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id) + #{keep_direct_replies_sql} + #{keep_statuses_with_local_replies} AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1)) AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id) @@ -199,5 +203,22 @@ module Mastodon::CLI ActiveRecord::Base.connection.execute('ANALYZE conversations') end end + + # keep statuses that have a local reply somewhere beneath it in the reply tree + def keep_statuses_with_local_replies + <<~SQL.squish + AND NOT EXISTS ( + with RECURSIVE thread_cte as + ( + SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id + UNION ALL + SELECT child.id, child.in_reply_to_id, child.uri, child.local + FROM statuses child + JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) + ) + SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) + ) + SQL + end end end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index 2597ad7f274..7c276051996 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -31,4 +31,33 @@ RSpec.describe Mastodon::CLI::Statuses do end end end + + describe '#keep_statuses_with_local_replies', :use_transactional_tests do + let(:action) { :remove } + + context 'with threaded replies' do + let!(:acct_remote) { Fabricate(:account, domain: 'example.com') } + let!(:acct_local) { Fabricate(:account) } + + let!(:root) { Fabricate(:status, account: acct_remote) } + let!(:reply_remote) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:reply_local) { Fabricate(:status, account: acct_local, in_reply_to_id: reply_remote.id) } + let!(:reply_leaf) { Fabricate(:status, account: acct_remote, in_reply_to_id: reply_local.id) } + let!(:reply_unrelated) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:unrelated) { Fabricate(:status, account: acct_remote) } + + it 'excludes statuses with local replies beneath them from pruning' do + obj = described_class.new + subject = obj.send(:keep_statuses_with_local_replies) + query = <<~SQL.squish + SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL + #{subject} + SQL + + to_delete = ActiveRecord::Base.connection.exec_query(query).to_ary.pluck('id') + + expect(to_delete.sort).to eq([reply_unrelated.id, unrelated.id, reply_leaf.id].sort) + end + end + end end From 717b2d4ecd0f44425df79d0650f909105caae7cb Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 10 Feb 2026 00:24:23 -0800 Subject: [PATCH 2/7] are constants flagged as sqli --- lib/mastodon/cli/statuses.rb | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 84b8ecd9f40..80231bad344 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -35,6 +35,20 @@ module Mastodon::CLI vacuum_and_analyze_conversations end + KEEP_STATUSES_WITH_LOCAL_REPLIES = <<~SQL.squish + AND NOT EXISTS ( + with RECURSIVE thread_cte as + ( + SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id + UNION ALL + SELECT child.id, child.in_reply_to_id, child.uri, child.local + FROM statuses child + JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) + ) + SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) + ) + SQL + private def remove_statuses @@ -59,7 +73,7 @@ module Mastodon::CLI INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) #{keep_direct_replies_sql} - #{keep_statuses_with_local_replies} + #{KEEP_STATUSES_WITH_LOCAL_REPLIES} AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1)) AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id) @@ -205,20 +219,6 @@ module Mastodon::CLI end # keep statuses that have a local reply somewhere beneath it in the reply tree - def keep_statuses_with_local_replies - <<~SQL.squish - AND NOT EXISTS ( - with RECURSIVE thread_cte as - ( - SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id - UNION ALL - SELECT child.id, child.in_reply_to_id, child.uri, child.local - FROM statuses child - JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) - ) - SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) - ) - SQL - end + def keep_statuses_with_local_replies; end end end From c967984be032afec6987a3fa828680a7efba8a3b Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 10 Feb 2026 00:27:07 -0800 Subject: [PATCH 3/7] cleanup post refactor to class constant --- lib/mastodon/cli/statuses.rb | 3 --- spec/lib/mastodon/cli/statuses_spec.rb | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 80231bad344..74b37db3ab0 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -217,8 +217,5 @@ module Mastodon::CLI ActiveRecord::Base.connection.execute('ANALYZE conversations') end end - - # keep statuses that have a local reply somewhere beneath it in the reply tree - def keep_statuses_with_local_replies; end end end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index 7c276051996..cb7b332345c 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Mastodon::CLI::Statuses do end end - describe '#keep_statuses_with_local_replies', :use_transactional_tests do + describe '#KEEP_STATUSES_WITH_LOCAL_REPLIES', :use_transactional_tests do let(:action) { :remove } context 'with threaded replies' do @@ -47,8 +47,7 @@ RSpec.describe Mastodon::CLI::Statuses do let!(:unrelated) { Fabricate(:status, account: acct_remote) } it 'excludes statuses with local replies beneath them from pruning' do - obj = described_class.new - subject = obj.send(:keep_statuses_with_local_replies) + subject = described_class::KEEP_STATUSES_WITH_LOCAL_REPLIES query = <<~SQL.squish SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL #{subject} From 4703f0f998a51190b1ff5fcd13ca25e3afb699d4 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Mon, 9 Feb 2026 20:18:17 -0800 Subject: [PATCH 4/7] prune statuses without a local reply beneath them --- lib/mastodon/cli/statuses.rb | 23 +++++++++++++++++++- spec/lib/mastodon/cli/statuses_spec.rb | 29 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index df0fcf0fbb0..84b8ecd9f40 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -52,10 +52,14 @@ module Mastodon::CLI # Skip accounts followed by local accounts clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed] + # keep direct replies to local statuses + keep_direct_replies_sql = 'AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.in_reply_to_id = statuses1.id AND (statuses1.uri IS NULL OR statuses1.local))' + ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL', [max_id]) INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) - AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id) + #{keep_direct_replies_sql} + #{keep_statuses_with_local_replies} AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1)) AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id) @@ -199,5 +203,22 @@ module Mastodon::CLI ActiveRecord::Base.connection.execute('ANALYZE conversations') end end + + # keep statuses that have a local reply somewhere beneath it in the reply tree + def keep_statuses_with_local_replies + <<~SQL.squish + AND NOT EXISTS ( + with RECURSIVE thread_cte as + ( + SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id + UNION ALL + SELECT child.id, child.in_reply_to_id, child.uri, child.local + FROM statuses child + JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) + ) + SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) + ) + SQL + end end end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index 2597ad7f274..7c276051996 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -31,4 +31,33 @@ RSpec.describe Mastodon::CLI::Statuses do end end end + + describe '#keep_statuses_with_local_replies', :use_transactional_tests do + let(:action) { :remove } + + context 'with threaded replies' do + let!(:acct_remote) { Fabricate(:account, domain: 'example.com') } + let!(:acct_local) { Fabricate(:account) } + + let!(:root) { Fabricate(:status, account: acct_remote) } + let!(:reply_remote) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:reply_local) { Fabricate(:status, account: acct_local, in_reply_to_id: reply_remote.id) } + let!(:reply_leaf) { Fabricate(:status, account: acct_remote, in_reply_to_id: reply_local.id) } + let!(:reply_unrelated) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:unrelated) { Fabricate(:status, account: acct_remote) } + + it 'excludes statuses with local replies beneath them from pruning' do + obj = described_class.new + subject = obj.send(:keep_statuses_with_local_replies) + query = <<~SQL.squish + SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL + #{subject} + SQL + + to_delete = ActiveRecord::Base.connection.exec_query(query).to_ary.pluck('id') + + expect(to_delete.sort).to eq([reply_unrelated.id, unrelated.id, reply_leaf.id].sort) + end + end + end end From 5294eca92291bf29f1be6a2c58e9df887bb8fe9a Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 10 Feb 2026 00:24:23 -0800 Subject: [PATCH 5/7] are constants flagged as sqli --- lib/mastodon/cli/statuses.rb | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 84b8ecd9f40..80231bad344 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -35,6 +35,20 @@ module Mastodon::CLI vacuum_and_analyze_conversations end + KEEP_STATUSES_WITH_LOCAL_REPLIES = <<~SQL.squish + AND NOT EXISTS ( + with RECURSIVE thread_cte as + ( + SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id + UNION ALL + SELECT child.id, child.in_reply_to_id, child.uri, child.local + FROM statuses child + JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) + ) + SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) + ) + SQL + private def remove_statuses @@ -59,7 +73,7 @@ module Mastodon::CLI INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) #{keep_direct_replies_sql} - #{keep_statuses_with_local_replies} + #{KEEP_STATUSES_WITH_LOCAL_REPLIES} AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1)) AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id) @@ -205,20 +219,6 @@ module Mastodon::CLI end # keep statuses that have a local reply somewhere beneath it in the reply tree - def keep_statuses_with_local_replies - <<~SQL.squish - AND NOT EXISTS ( - with RECURSIVE thread_cte as - ( - SELECT id, in_reply_to_id, uri, local from statuses parent WHERE id = statuses.id - UNION ALL - SELECT child.id, child.in_reply_to_id, child.uri, child.local - FROM statuses child - JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) - ) - SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) - ) - SQL - end + def keep_statuses_with_local_replies; end end end From c8efce1791280b8cc10c9f3e7508c51a8829123f Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 10 Feb 2026 00:27:07 -0800 Subject: [PATCH 6/7] cleanup post refactor to class constant --- lib/mastodon/cli/statuses.rb | 3 --- spec/lib/mastodon/cli/statuses_spec.rb | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 80231bad344..74b37db3ab0 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -217,8 +217,5 @@ module Mastodon::CLI ActiveRecord::Base.connection.execute('ANALYZE conversations') end end - - # keep statuses that have a local reply somewhere beneath it in the reply tree - def keep_statuses_with_local_replies; end end end diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index 7c276051996..cb7b332345c 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Mastodon::CLI::Statuses do end end - describe '#keep_statuses_with_local_replies', :use_transactional_tests do + describe '#KEEP_STATUSES_WITH_LOCAL_REPLIES', :use_transactional_tests do let(:action) { :remove } context 'with threaded replies' do @@ -47,8 +47,7 @@ RSpec.describe Mastodon::CLI::Statuses do let!(:unrelated) { Fabricate(:status, account: acct_remote) } it 'excludes statuses with local replies beneath them from pruning' do - obj = described_class.new - subject = obj.send(:keep_statuses_with_local_replies) + subject = described_class::KEEP_STATUSES_WITH_LOCAL_REPLIES query = <<~SQL.squish SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL #{subject} From 742a7b289f6deaa7781bd4d6502b3aa3bea4a2e0 Mon Sep 17 00:00:00 2001 From: sneakers-the-rat Date: Tue, 10 Feb 2026 18:32:24 -0800 Subject: [PATCH 7/7] keep parent statuses with downthread bookmarks, statuses, and quotes --- lib/mastodon/cli/statuses.rb | 18 +++++-- spec/lib/mastodon/cli/statuses_spec.rb | 70 ++++++++++++++++++-------- 2 files changed, 65 insertions(+), 23 deletions(-) diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 74b37db3ab0..f101e3d1d8c 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -35,7 +35,14 @@ module Mastodon::CLI vacuum_and_analyze_conversations end - KEEP_STATUSES_WITH_LOCAL_REPLIES = <<~SQL.squish + # Preserve thread parents when there is some local interaction in the tree of replies beneath to avoid breaking threads, + # specifically the interactions that imply that the user intends to view the status again, + # or another account viewing the interaction would need to see thread parents as context: + # - local replies + # - local bookmarks + # - local reblogs + # - local quotes + KEEP_THREAD_PARENTS_WITH_LOCAL_INTERACTIONS = <<~SQL.squish AND NOT EXISTS ( with RECURSIVE thread_cte as ( @@ -45,7 +52,12 @@ module Mastodon::CLI FROM statuses child JOIN thread_cte ON (child.in_reply_to_id = thread_cte.id) ) - SELECT 1 FROM thread_cte WHERE (thread_cte.uri IS NULL OR thread_cte.local) + SELECT 1 FROM thread_cte WHERE ( + (thread_cte.uri IS NULL OR thread_cte.local) + OR EXISTS (SELECT 1 FROM bookmarks WHERE thread_cte.id = bookmarks.status_id AND bookmarks.account_id IN (SELECT accounts.id FROM accounts WHERE domain IS NULL)) + OR EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE thread_cte.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) + OR EXISTS (SELECT 1 FROM quotes JOIN statuses statuses1 ON quotes.status_id = statuses1.id WHERE quotes.quoted_status_id = thread_cte.id AND (statuses1.uri IS NULL OR statuses1.local)) + ) ) SQL @@ -73,7 +85,7 @@ module Mastodon::CLI INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) #{keep_direct_replies_sql} - #{KEEP_STATUSES_WITH_LOCAL_REPLIES} + #{KEEP_THREAD_PARENTS_WITH_LOCAL_INTERACTIONS} AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses1.id = statuses.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local)) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.reblog_of_id AND (statuses1.uri IS NULL OR statuses1.local OR statuses1.id >= $1)) AND NOT EXISTS (SELECT 1 FROM status_pins WHERE statuses.id = status_id) diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb index cb7b332345c..cb6a78397f6 100644 --- a/spec/lib/mastodon/cli/statuses_spec.rb +++ b/spec/lib/mastodon/cli/statuses_spec.rb @@ -32,31 +32,61 @@ RSpec.describe Mastodon::CLI::Statuses do end end - describe '#KEEP_STATUSES_WITH_LOCAL_REPLIES', :use_transactional_tests do - let(:action) { :remove } + describe '#KEEP_THREAD_PARENTS_WITH_LOCAL_INTERACTIONS', :use_transactional_tests do + subject do + query = <<~SQL.squish + SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL + #{described_class::KEEP_THREAD_PARENTS_WITH_LOCAL_INTERACTIONS} + SQL - context 'with threaded replies' do - let!(:acct_remote) { Fabricate(:account, domain: 'example.com') } - let!(:acct_local) { Fabricate(:account) } + ActiveRecord::Base.connection.exec_query(query).to_ary.pluck('id') + end - let!(:root) { Fabricate(:status, account: acct_remote) } - let!(:reply_remote) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } - let!(:reply_local) { Fabricate(:status, account: acct_local, in_reply_to_id: reply_remote.id) } - let!(:reply_leaf) { Fabricate(:status, account: acct_remote, in_reply_to_id: reply_local.id) } - let!(:reply_unrelated) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } - let!(:unrelated) { Fabricate(:status, account: acct_remote) } + let!(:acct_local) { Fabricate(:account) } + let!(:acct_remote) { Fabricate(:account, domain: 'example.com') } + let!(:acct_target) { Fabricate(:account, domain: 'example.com') } - it 'excludes statuses with local replies beneath them from pruning' do - subject = described_class::KEEP_STATUSES_WITH_LOCAL_REPLIES - query = <<~SQL.squish - SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL - #{subject} - SQL + let!(:root) { Fabricate(:status, account: acct_remote) } + let!(:reply_remote) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:reply_target) { Fabricate(:status, account: acct_target, in_reply_to_id: reply_remote.id) } + let!(:reply_leaf) { Fabricate(:status, account: acct_remote, in_reply_to_id: reply_target.id) } + let!(:reply_unrelated) { Fabricate(:status, account: acct_remote, in_reply_to_id: root.id) } + let!(:unrelated) { Fabricate(:status, account: acct_remote) } - to_delete = ActiveRecord::Base.connection.exec_query(query).to_ary.pluck('id') - - expect(to_delete.sort).to eq([reply_unrelated.id, unrelated.id, reply_leaf.id].sort) + shared_examples 'preserves thread parents' do + it 'excludes statuses with downthread interactions from pruning' do + expect(subject).to contain_exactly(reply_unrelated.id, unrelated.id, reply_leaf.id) end end + + context 'without local interaction' do + it 'prunes the entire thread' do + expect(subject).to contain_exactly(root.id, reply_remote.id, reply_target.id, reply_leaf.id, reply_unrelated.id, unrelated.id) + end + end + + context 'with a local reply' do + let!(:acct_target) { acct_local } + + it_behaves_like 'preserves thread parents' + end + + context 'with a local bookmark' do + before { Fabricate(:bookmark, account: acct_local, status: reply_target) } + + it_behaves_like 'preserves thread parents' + end + + context 'with a local reblog' do + before { Fabricate(:status, account: acct_local, reblog_of_id: reply_target.id) } + + it_behaves_like 'preserves thread parents' + end + + context 'with a local quote' do + before { Fabricate(:quote, status: Fabricate(:status, account: acct_local), quoted_status: reply_target, state: :accepted) } + + it_behaves_like 'preserves thread parents' + end end end