about summary refs log tree commit diff
path: root/spec
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
committerStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
commitc0341f06be5310a00b85a5d48fa80891d47c6710 (patch)
tree907ef7f787f8bd446a6d9be1448a8bcff74e5a08 /spec
parent169688aa9f2a69ac3d36332c833e9cad43b5f7a5 (diff)
parent6f78c66fe01921a4e7e01aa6e2386a5fce7f3afd (diff)
Merge remote-tracking branch 'glitch/main'
Not at all sure where the admin UI is going to display English language
names now but OK.
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/activitypub/followers_synchronizations_controller_spec.rb2
-rw-r--r--spec/controllers/activitypub/replies_controller_spec.rb293
-rw-r--r--spec/controllers/api/v1/accounts/statuses_controller_spec.rb2
-rw-r--r--spec/controllers/api/v1/media_controller_spec.rb21
-rw-r--r--spec/controllers/api/v1/statuses_controller_spec.rb17
-rw-r--r--spec/helpers/languages_helper_spec.rb18
-rw-r--r--spec/lib/language_detector_spec.rb134
-rw-r--r--spec/lib/link_details_extractor_spec.rb122
-rw-r--r--spec/models/account_statuses_cleanup_policy_spec.rb2
-rw-r--r--spec/models/status_spec.rb32
-rw-r--r--spec/policies/status_policy_spec.rb16
-rw-r--r--spec/services/activitypub/fetch_replies_service_spec.rb18
-rw-r--r--spec/services/activitypub/process_status_update_service_spec.rb248
-rw-r--r--spec/services/remove_status_service_spec.rb106
-rw-r--r--spec/services/update_status_service_spec.rb140
-rw-r--r--spec/spec_helper.rb7
-rw-r--r--spec/workers/activitypub/distribute_poll_update_worker_spec.rb3
-rw-r--r--spec/workers/activitypub/distribution_worker_spec.rb7
-rw-r--r--spec/workers/activitypub/move_distribution_worker_spec.rb6
-rw-r--r--spec/workers/activitypub/status_update_distribution_worker_spec.rb44
-rw-r--r--spec/workers/activitypub/update_distribution_worker_spec.rb3
-rw-r--r--spec/workers/move_worker_spec.rb5
22 files changed, 866 insertions, 380 deletions
diff --git a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
index 3a382ff27..e233bd560 100644
--- a/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
+++ b/spec/controllers/activitypub/followers_synchronizations_controller_spec.rb
@@ -47,7 +47,7 @@ RSpec.describe ActivityPub::FollowersSynchronizationsController, type: :controll
 
       it 'returns orderedItems with followers from example.com' do
         expect(body[:orderedItems]).to be_an Array
-        expect(body[:orderedItems].sort).to eq [follower_4.uri, follower_1.uri, follower_2.uri]
+        expect(body[:orderedItems]).to match_array([follower_4.uri, follower_1.uri, follower_2.uri])
       end
 
       it 'returns private Cache-Control header' do
diff --git a/spec/controllers/activitypub/replies_controller_spec.rb b/spec/controllers/activitypub/replies_controller_spec.rb
index bf82fd020..a2c7f336f 100644
--- a/spec/controllers/activitypub/replies_controller_spec.rb
+++ b/spec/controllers/activitypub/replies_controller_spec.rb
@@ -4,8 +4,9 @@ require 'rails_helper'
 
 RSpec.describe ActivityPub::RepliesController, type: :controller do
   let(:status) { Fabricate(:status, visibility: parent_visibility) }
-  let(:remote_reply_id) { nil }
-  let(:remote_account) { nil }
+  let(:remote_account)  { Fabricate(:account, domain: 'foobar.com') }
+  let(:remote_reply_id) { 'https://foobar.com/statuses/1234' }
+  let(:remote_querier) { nil }
 
   shared_examples 'cachable response' do
     it 'does not set cookies' do
@@ -23,224 +24,188 @@ RSpec.describe ActivityPub::RepliesController, type: :controller do
     end
   end
 
-  before do
-    allow(controller).to receive(:signed_request_account).and_return(remote_account)
+  shared_examples 'common behavior' do
+    context 'when status is private' do
+      let(:parent_visibility) { :private }
 
-    Fabricate(:status, thread: status, visibility: :public)
-    Fabricate(:status, thread: status, visibility: :public)
-    Fabricate(:status, thread: status, visibility: :private)
-    Fabricate(:status, account: status.account, thread: status, visibility: :public)
-    Fabricate(:status, account: status.account, thread: status, visibility: :private)
-
-    Fabricate(:status, account: remote_account, thread: status, visibility: :public, uri: remote_reply_id) if remote_reply_id
-  end
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
+      end
+    end
 
-  describe 'GET #index' do
-    context 'with no signature' do
-      subject(:response) { get :index, params: { account_username: status.account.username, status_id: status.id } }
-      subject(:body) { body_as_json }
+    context 'when status is direct' do
+      let(:parent_visibility) { :direct }
 
-      context 'when account is permanently suspended' do
-        let(:parent_visibility) { :public }
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
+      end
+    end
+  end
 
-        before do
-          status.account.suspend!
-          status.account.deletion_request.destroy
-        end
+  shared_examples 'disallowed access' do
+    context 'when status is public' do
+      let(:parent_visibility) { :public }
 
-        it 'returns http gone' do
-          expect(response).to have_http_status(410)
-        end
+      it 'returns http not found' do
+        expect(response).to have_http_status(404)
       end
+    end
 
-      context 'when account is temporarily suspended' do
-        let(:parent_visibility) { :public }
+    it_behaves_like 'common behavior'
+  end
 
-        before do
-          status.account.suspend!
-        end
+  shared_examples 'allowed access' do
+    context 'when account is permanently suspended' do
+      let(:parent_visibility) { :public }
 
-        it 'returns http forbidden' do
-          expect(response).to have_http_status(403)
-        end
+      before do
+        status.account.suspend!
+        status.account.deletion_request.destroy
       end
 
-      context 'when status is public' do
-        let(:parent_visibility) { :public }
-
-        it 'returns http success' do
-          expect(response).to have_http_status(200)
-        end
+      it 'returns http gone' do
+        expect(response).to have_http_status(410)
+      end
+    end
 
-        it 'returns application/activity+json' do
-          expect(response.media_type).to eq 'application/activity+json'
-        end
+    context 'when account is temporarily suspended' do
+      let(:parent_visibility) { :public }
 
-        it_behaves_like 'cachable response'
+      before do
+        status.account.suspend!
+      end
 
-        it 'returns items with account\'s own replies' do
-          expect(body[:first]).to be_a Hash
-          expect(body[:first][:items]).to be_an Array
-          expect(body[:first][:items].size).to eq 1
-          expect(body[:first][:items].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
-        end
+      it 'returns http forbidden' do
+        expect(response).to have_http_status(403)
       end
+    end
 
-      context 'when status is private' do
-        let(:parent_visibility) { :private }
+    context 'when status is public' do
+      let(:parent_visibility) { :public }
+      let(:json) { body_as_json }
+      let(:page_json) { json[:first] }
 
-        it 'returns http not found' do
-          expect(response).to have_http_status(404)
-        end
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
       end
 
-      context 'when status is direct' do
-        let(:parent_visibility) { :direct }
-
-        it 'returns http not found' do
-          expect(response).to have_http_status(404)
-        end
+      it 'returns application/activity+json' do
+        expect(response.media_type).to eq 'application/activity+json'
       end
-    end
 
-    context 'with signature' do
-      let(:remote_account) { Fabricate(:account, domain: 'example.com') }
-      let(:only_other_accounts) { nil }
+      it_behaves_like 'cachable response'
 
-      context do
-        before do
-          get :index, params: { account_username: status.account.username, status_id: status.id, only_other_accounts: only_other_accounts }
+      context 'without only_other_accounts' do
+        it "returns items with thread author's replies" do
+          expect(page_json).to be_a Hash
+          expect(page_json[:items]).to be_an Array
+          expect(page_json[:items].size).to eq 1
+          expect(page_json[:items].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
         end
 
-        context 'when status is public' do
-          let(:parent_visibility) { :public }
-
-          it 'returns http success' do
-            expect(response).to have_http_status(200)
+        context 'when there are few self-replies' do
+          it 'points next to replies from other people' do
+            expect(page_json).to be_a Hash
+            expect(Addressable::URI.parse(page_json[:next]).query.split('&')).to include('only_other_accounts=true', 'page=true')
           end
+        end
 
-          it 'returns application/activity+json' do
-            expect(response.media_type).to eq 'application/activity+json'
+        context 'when there are many self-replies' do
+          before do
+            10.times { Fabricate(:status, account: status.account, thread: status, visibility: :public) }
           end
 
-          it_behaves_like 'cachable response'
-
-          context 'without only_other_accounts' do
-            it 'returns items with account\'s own replies' do
-              json = body_as_json
-
-              expect(json[:first]).to be_a Hash
-              expect(json[:first][:items]).to be_an Array
-              expect(json[:first][:items].size).to eq 1
-              expect(json[:first][:items].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
-            end
+          it 'points next to other self-replies' do
+            expect(page_json).to be_a Hash
+            expect(Addressable::URI.parse(page_json[:next]).query.split('&')).to include('only_other_accounts=false', 'page=true')
           end
+        end
+      end
 
-          context 'with only_other_accounts' do
-            let(:only_other_accounts) { 'true' }
-
-            it 'returns items with other public or unlisted replies' do
-              json = body_as_json
-
-              expect(json[:first]).to be_a Hash
-              expect(json[:first][:items]).to be_an Array
-              expect(json[:first][:items].size).to eq 2
-              expect(json[:first][:items].all? { |item| item[:to].include?(ActivityPub::TagManager::COLLECTIONS[:public]) || item[:cc].include?(ActivityPub::TagManager::COLLECTIONS[:public]) }).to be true
-            end
-
-            context 'with remote responses' do
-              let(:remote_reply_id) { 'foo' }
+      context 'with only_other_accounts' do
+        let(:only_other_accounts) { 'true' }
 
-              it 'returned items are all inlined local toots or are ids' do
-                json = body_as_json
+        it 'returns items with other public or unlisted replies' do
+          expect(page_json).to be_a Hash
+          expect(page_json[:items]).to be_an Array
+          expect(page_json[:items].size).to eq 3
+        end
 
-                expect(json[:first]).to be_a Hash
-                expect(json[:first][:items]).to be_an Array
-                expect(json[:first][:items].size).to eq 3
-                expect(json[:first][:items].all? { |item| item.is_a?(Hash) ? ActivityPub::TagManager.instance.local_uri?(item[:id]) : item.is_a?(String) }).to be true
-                expect(json[:first][:items]).to include remote_reply_id
-              end
-            end
-          end
+        it 'only inlines items that are local and public or unlisted replies' do
+          inlined_replies = page_json[:items].select { |x| x.is_a?(Hash) }
+          public_collection = ActivityPub::TagManager::COLLECTIONS[:public]
+          expect(inlined_replies.all? { |item| item[:to].include?(public_collection) || item[:cc].include?(public_collection) }).to be true
+          expect(inlined_replies.all? { |item| ActivityPub::TagManager.instance.local_uri?(item[:id]) }).to be true
         end
 
-        context 'when status is private' do
-          let(:parent_visibility) { :private }
+        it 'uses ids for remote toots' do
+          remote_replies = page_json[:items].select { |x| !x.is_a?(Hash) }
+          expect(remote_replies.all? { |item| item.is_a?(String) && !ActivityPub::TagManager.instance.local_uri?(item) }).to be true
+        end
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
+        context 'when there are few replies' do
+          it 'does not have a next page' do
+            expect(page_json).to be_a Hash
+            expect(page_json[:next]).to be_nil
           end
         end
 
-        context 'when status is direct' do
-          let(:parent_visibility) { :direct }
+        context 'when there are many replies' do
+          before do
+            10.times { Fabricate(:status, thread: status, visibility: :public) }
+          end
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
+          it 'points next to other replies' do
+            expect(page_json).to be_a Hash
+            expect(Addressable::URI.parse(page_json[:next]).query.split('&')).to include('only_other_accounts=true', 'page=true')
           end
         end
       end
+    end
 
-      context 'when signed request account is blocked' do
-        before do
-          status.account.block!(remote_account)
-          get :index, params: { account_username: status.account.username, status_id: status.id }
-        end
-
-        context 'when status is public' do
-          let(:parent_visibility) { :public }
+    it_behaves_like 'common behavior'
+  end
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
-        end
+  before do
+    stub_const 'ActivityPub::RepliesController::DESCENDANTS_LIMIT', 5
+    allow(controller).to receive(:signed_request_account).and_return(remote_querier)
 
-        context 'when status is private' do
-          let(:parent_visibility) { :private }
+    Fabricate(:status, thread: status, visibility: :public)
+    Fabricate(:status, thread: status, visibility: :public)
+    Fabricate(:status, thread: status, visibility: :private)
+    Fabricate(:status, account: status.account, thread: status, visibility: :public)
+    Fabricate(:status, account: status.account, thread: status, visibility: :private)
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
-        end
+    Fabricate(:status, account: remote_account, thread: status, visibility: :public, uri: remote_reply_id)
+  end
 
-        context 'when status is direct' do
-          let(:parent_visibility) { :direct }
+  describe 'GET #index' do
+    subject(:response) { get :index, params: { account_username: status.account.username, status_id: status.id, only_other_accounts: only_other_accounts } }
+    let(:only_other_accounts) { nil }
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
-        end
-      end
+    context 'with no signature' do
+      it_behaves_like 'allowed access'
+    end
 
-      context 'when signed request account is domain blocked' do
-        before do
-          status.account.block_domain!(remote_account.domain)
-          get :index, params: { account_username: status.account.username, status_id: status.id }
-        end
+    context 'with signature' do
+      let(:remote_querier) { Fabricate(:account, domain: 'example.com') }
 
-        context 'when status is public' do
-          let(:parent_visibility) { :public }
+      it_behaves_like 'allowed access'
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
+      context 'when signed request account is blocked' do
+        before do
+          status.account.block!(remote_querier)
         end
 
-        context 'when status is private' do
-          let(:parent_visibility) { :private }
+        it_behaves_like 'disallowed access'
+      end
 
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
+      context 'when signed request account is domain blocked' do
+        before do
+          status.account.block_domain!(remote_querier.domain)
         end
 
-        context 'when status is direct' do
-          let(:parent_visibility) { :direct }
-
-          it 'returns http not found' do
-            expect(response).to have_http_status(404)
-          end
-        end
+        it_behaves_like 'disallowed access'
       end
     end
   end
diff --git a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
index 348de08c2..b962b3398 100644
--- a/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/statuses_controller_spec.rb
@@ -79,7 +79,7 @@ describe Api::V1::Accounts::StatusesController do
         it 'lists both the public and the private statuses' do
           get :index, params: { account_id: account.id, pinned: true }
           json = body_as_json
-          expect(json.map { |item| item[:id].to_i }.sort).to eq [status.id, private_status.id].sort
+          expect(json.map { |item| item[:id].to_i }).to match_array([status.id, private_status.id])
         end
       end
     end
diff --git a/spec/controllers/api/v1/media_controller_spec.rb b/spec/controllers/api/v1/media_controller_spec.rb
index d8d732630..a1f6ddb24 100644
--- a/spec/controllers/api/v1/media_controller_spec.rb
+++ b/spec/controllers/api/v1/media_controller_spec.rb
@@ -110,21 +110,24 @@ RSpec.describe Api::V1::MediaController, type: :controller do
       end
     end
 
-    context 'when not attached to a status' do
-      let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) }
+    context 'when the author \'s' do
+      let(:status) { nil }
+      let(:media)  { Fabricate(:media_attachment, status: status, account: user.account) }
 
-      it 'updates the description' do
+      before do
         put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
+      end
+
+      it 'updates the description' do
         expect(media.reload.description).to eq 'Lorem ipsum!!!'
       end
-    end
 
-    context 'when attached to a status' do
-      let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) }
+      context 'when already attached to a status' do
+        let(:status) { Fabricate(:status, account: user.account) }
 
-      it 'returns http not found' do
-        put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
-        expect(response).to have_http_status(:not_found)
+        it 'returns http not found' do
+          expect(response).to have_http_status(:not_found)
+        end
       end
     end
   end
diff --git a/spec/controllers/api/v1/statuses_controller_spec.rb b/spec/controllers/api/v1/statuses_controller_spec.rb
index 2679ab017..190dfad11 100644
--- a/spec/controllers/api/v1/statuses_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses_controller_spec.rb
@@ -102,6 +102,23 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
         expect(Status.find_by(id: status.id)).to be nil
       end
     end
+
+    describe 'PUT #update' do
+      let(:scopes) { 'write:statuses' }
+      let(:status) { Fabricate(:status, account: user.account) }
+
+      before do
+        put :update, params: { id: status.id, status: 'I am updated' }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'updates the status' do
+        expect(status.reload.text).to eq 'I am updated'
+      end
+    end
   end
 
   context 'without an oauth token' do
diff --git a/spec/helpers/languages_helper_spec.rb b/spec/helpers/languages_helper_spec.rb
index 6db617824..5587fc261 100644
--- a/spec/helpers/languages_helper_spec.rb
+++ b/spec/helpers/languages_helper_spec.rb
@@ -3,15 +3,21 @@
 require 'rails_helper'
 
 describe LanguagesHelper do
-  describe 'the HUMAN_LOCALES constant' do
-    it 'includes all I18n locales' do
-      expect(described_class::HUMAN_LOCALES.keys).to include(*I18n.available_locales)
+  describe 'the SUPPORTED_LOCALES constant' do
+    it 'includes all i18n locales' do
+      expect(Set.new(described_class::SUPPORTED_LOCALES.keys + described_class::REGIONAL_LOCALE_NAMES.keys)).to include(*I18n.available_locales)
     end
   end
 
-  describe 'human_locale' do
-    it 'finds the human readable local description from a key' do
-      expect(helper.human_locale(:en)).to eq('English')
+  describe 'native_locale_name' do
+    it 'finds the human readable native name from a key' do
+      expect(helper.native_locale_name(:en)).to eq('English')
+    end
+  end
+
+  describe 'standard_locale_name' do
+    it 'finds the human readable standard name from a key' do
+      expect(helper.standard_locale_name(:de)).to eq('German')
     end
   end
 end
diff --git a/spec/lib/language_detector_spec.rb b/spec/lib/language_detector_spec.rb
deleted file mode 100644
index b7ba0f6c4..000000000
--- a/spec/lib/language_detector_spec.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe LanguageDetector do
-  describe 'prepare_text' do
-    it 'returns unmodified string without special cases' do
-      string = 'just a regular string'
-      result = described_class.instance.send(:prepare_text, string)
-
-      expect(result).to eq string
-    end
-
-    it 'collapses spacing in strings' do
-      string = 'The formatting   in    this is very        odd'
-
-      result = described_class.instance.send(:prepare_text, string)
-      expect(result).to eq 'The formatting in this is very odd'
-    end
-
-    it 'strips usernames from strings before detection' do
-      string = '@username Yeah, very surreal...! also @friend'
-
-      result = described_class.instance.send(:prepare_text, string)
-      expect(result).to eq 'Yeah, very surreal...! also'
-    end
-
-    it 'strips URLs from strings before detection' do
-      string = 'Our website is https://example.com and also http://localhost.dev'
-
-      result = described_class.instance.send(:prepare_text, string)
-      expect(result).to eq 'Our website is and also'
-    end
-
-    it 'converts #hashtags back to normal text before detection' do
-      string = 'Hey look at all the #animals and #FishAndChips'
-
-      result = described_class.instance.send(:prepare_text, string)
-      expect(result).to eq 'Hey look at all the animals and fish and chips'
-    end
-  end
-
-  describe 'detect' do
-    let(:account_without_user_locale) { Fabricate(:user, locale: nil).account }
-    let(:account_remote) { Fabricate(:account, domain: 'joinmastodon.org') }
-
-    it 'detects english language for basic strings' do
-      strings = [
-        "Hello and welcome to mastodon how are you today?",
-        "I'd rather not!",
-        "a lot of people just want to feel righteous all the time and that's all that matters",
-      ]
-      strings.each do |string|
-        result = described_class.instance.detect(string, account_without_user_locale)
-
-        expect(result).to eq(:en), string
-      end
-    end
-
-    it 'detects spanish language' do
-      string = 'Obtener un Hola y bienvenidos a Mastodon. Obtener un Hola y bienvenidos a Mastodon. Obtener un Hola y bienvenidos a Mastodon. Obtener un Hola y bienvenidos a Mastodon'
-      result = described_class.instance.detect(string, account_without_user_locale)
-
-      expect(result).to eq :es
-    end
-
-    describe 'when language can\'t be detected' do
-      it 'uses nil when sent an empty document' do
-        result = described_class.instance.detect('', account_without_user_locale)
-        expect(result).to eq nil
-      end
-
-      describe 'because of a URL' do
-        it 'uses nil when sent just a URL' do
-          string = 'http://example.com/media/2kFTgOJLXhQf0g2nKB4'
-          cld_result = CLD3::NNetLanguageIdentifier.new(0, 2048).find_language(string)
-          expect(cld_result).not_to eq :en
-
-          result = described_class.instance.detect(string, account_without_user_locale)
-
-          expect(result).to eq nil
-        end
-      end
-
-      describe 'with an account' do
-        it 'uses the account locale when present' do
-          account = double(user_locale: 'fr')
-          result  = described_class.instance.detect('', account)
-
-          expect(result).to eq nil
-        end
-
-        it 'uses nil when account is present but has no locale' do
-          result = described_class.instance.detect('', account_without_user_locale)
-
-          expect(result).to eq nil
-        end
-      end
-
-      describe 'with an `en` default locale' do
-        it 'uses nil for undetectable string' do
-          result = described_class.instance.detect('', account_without_user_locale)
-
-          expect(result).to eq nil
-        end
-      end
-
-      describe 'remote user' do
-        it 'detects Korean language' do
-          string = '안녕하세요'
-          result = described_class.instance.detect(string, account_remote)
-
-          expect(result).to eq :ko
-        end
-      end
-
-      describe 'with a non-`en` default locale' do
-        around(:each) do |example|
-          before = I18n.default_locale
-          I18n.default_locale = :ja
-          example.run
-          I18n.default_locale = before
-        end
-
-        it 'uses nil for undetectable string' do
-          string = ''
-          result = described_class.instance.detect(string, account_without_user_locale)
-
-          expect(result).to eq nil
-        end
-      end
-    end
-  end
-end
diff --git a/spec/lib/link_details_extractor_spec.rb b/spec/lib/link_details_extractor_spec.rb
index 850857b2d..84bb4579c 100644
--- a/spec/lib/link_details_extractor_spec.rb
+++ b/spec/lib/link_details_extractor_spec.rb
@@ -26,4 +26,126 @@ RSpec.describe LinkDetailsExtractor do
       end
     end
   end
+
+  context 'when structured data is present' do
+    let(:original_url) { 'https://example.com/page.html' }
+
+    context 'and is wrapped in CDATA tags' do
+      let(:html) { <<-HTML }
+<!doctype html>
+<html>
+<head>
+  <script type="application/ld+json">
+  //<![CDATA[
+  {"@context":"http://schema.org","@type":"NewsArticle","mainEntityOfPage":"https://example.com/page.html","headline":"Foo","datePublished":"2022-01-31T19:53:00+00:00","url":"https://example.com/page.html","description":"Bar","author":{"@type":"Person","name":"Hoge"},"publisher":{"@type":"Organization","name":"Baz"}}
+  //]]>
+  </script>
+</head>
+</html>
+      HTML
+
+      describe '#title' do
+        it 'returns the title from structured data' do
+          expect(subject.title).to eq 'Foo'
+        end
+      end
+
+      describe '#description' do
+        it 'returns the description from structured data' do
+          expect(subject.description).to eq 'Bar'
+        end
+      end
+
+      describe '#provider_name' do
+        it 'returns the provider name from structured data' do
+          expect(subject.provider_name).to eq 'Baz'
+        end
+      end
+
+      describe '#author_name' do
+        it 'returns the author name from structured data' do
+          expect(subject.author_name).to eq 'Hoge'
+        end
+      end
+    end
+
+    context 'but the first tag is invalid JSON' do
+      let(:html) { <<-HTML }
+<!doctype html>
+<html>
+<body>
+  <script type="application/ld+json">
+    {
+      "@context":"https://schema.org",
+      "@type":"ItemList",
+      "url":"https://example.com/page.html",
+      "name":"Foo",
+      "description":"Bar"
+    },
+    {
+      "@context": "https://schema.org",
+      "@type": "BreadcrumbList",
+      "itemListElement":[
+        {
+          "@type":"ListItem",
+          "position":1,
+          "item":{
+            "@id":"https://www.example.com",
+            "name":"Baz"
+          }
+        }
+      ]
+    }
+  </script>
+  <script type="application/ld+json">
+    {
+      "@context":"https://schema.org",
+      "@type":"NewsArticle",
+      "mainEntityOfPage": {
+        "@type":"WebPage",
+        "@id": "http://example.com/page.html"
+      },
+      "headline": "Foo",
+      "description": "Bar",
+      "datePublished": "2022-01-31T19:46:00+00:00",
+      "author": {
+        "@type": "Organization",
+        "name": "Hoge"
+      },
+      "publisher": {
+        "@type": "NewsMediaOrganization",
+        "name":"Baz",
+        "url":"https://example.com/"
+      }
+    }
+  </script>
+</body>
+</html>
+      HTML
+
+      describe '#title' do
+        it 'returns the title from structured data' do
+          expect(subject.title).to eq 'Foo'
+        end
+      end
+
+      describe '#description' do
+        it 'returns the description from structured data' do
+          expect(subject.description).to eq 'Bar'
+        end
+      end
+
+      describe '#provider_name' do
+        it 'returns the provider name from structured data' do
+          expect(subject.provider_name).to eq 'Baz'
+        end
+      end
+
+      describe '#author_name' do
+        it 'returns the author name from structured data' do
+          expect(subject.author_name).to eq 'Hoge'
+        end
+      end
+    end
+  end
 end
diff --git a/spec/models/account_statuses_cleanup_policy_spec.rb b/spec/models/account_statuses_cleanup_policy_spec.rb
index 4732ad625..b01321a20 100644
--- a/spec/models/account_statuses_cleanup_policy_spec.rb
+++ b/spec/models/account_statuses_cleanup_policy_spec.rb
@@ -495,7 +495,7 @@ RSpec.describe AccountStatusesCleanupPolicy, type: :model do
       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
+        expect(subject.pluck(:id)).to match_array([very_old_status.id, faved4.id, faved5.id, reblogged4.id, reblogged5.id])
       end
     end
 
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 25c98d508..029789a11 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -366,17 +366,17 @@ RSpec.describe Status, type: :model do
 
     context 'when given one tag' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id]
-        expect(Status.tagged_with([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id]
-        expect(Status.tagged_with([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status5.id]
+        expect(Status.tagged_with([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status5.id])
+        expect(Status.tagged_with([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status5.id])
+        expect(Status.tagged_with([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id, status5.id])
       end
     end
 
     context 'when given multiple tags' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status5.id]
-        expect(Status.tagged_with([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status5.id]
-        expect(Status.tagged_with([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status5.id]
+        expect(Status.tagged_with([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status2.id, status5.id])
+        expect(Status.tagged_with([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status3.id, status5.id])
+        expect(Status.tagged_with([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status3.id, status5.id])
       end
     end
   end
@@ -393,15 +393,15 @@ RSpec.describe Status, type: :model do
 
     context 'when given one tag' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with_all([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status5.id]
-        expect(Status.tagged_with_all([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status5.id]
-        expect(Status.tagged_with_all([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id]
+        expect(Status.tagged_with_all([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status5.id])
+        expect(Status.tagged_with_all([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status5.id])
+        expect(Status.tagged_with_all([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id])
       end
     end
 
     context 'when given multiple tags' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with_all([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status5.id]
+        expect(Status.tagged_with_all([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status5.id])
         expect(Status.tagged_with_all([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq []
         expect(Status.tagged_with_all([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq []
       end
@@ -420,17 +420,17 @@ RSpec.describe Status, type: :model do
 
     context 'when given one tag' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with_none([tag1.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status3.id, status4.id]
-        expect(Status.tagged_with_none([tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status3.id, status4.id]
-        expect(Status.tagged_with_none([tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status2.id, status4.id]
+        expect(Status.tagged_with_none([tag1.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status3.id, status4.id])
+        expect(Status.tagged_with_none([tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status3.id, status4.id])
+        expect(Status.tagged_with_none([tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status2.id, status4.id])
       end
     end
 
     context 'when given multiple tags' do
       it 'returns the expected statuses' do
-        expect(Status.tagged_with_none([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to eq [status3.id, status4.id]
-        expect(Status.tagged_with_none([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status2.id, status4.id]
-        expect(Status.tagged_with_none([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to eq [status1.id, status4.id]
+        expect(Status.tagged_with_none([tag1.id, tag2.id]).reorder(:id).pluck(:id).uniq).to match_array([status3.id, status4.id])
+        expect(Status.tagged_with_none([tag1.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status2.id, status4.id])
+        expect(Status.tagged_with_none([tag2.id, tag3.id]).reorder(:id).pluck(:id).uniq).to match_array([status1.id, status4.id])
       end
     end
   end
diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb
index 8bce29cad..865c693aa 100644
--- a/spec/policies/status_policy_spec.rb
+++ b/spec/policies/status_policy_spec.rb
@@ -137,7 +137,7 @@ RSpec.describe StatusPolicy, type: :model do
     end
   end
 
-  permissions :index?, :update? do
+  permissions :index? do
     it 'grants access if staff' do
       expect(subject).to permit(admin.account)
     end
@@ -146,4 +146,18 @@ RSpec.describe StatusPolicy, type: :model do
       expect(subject).to_not permit(alice)
     end
   end
+
+  permissions :update? do
+    it 'grants access if staff' do
+      expect(subject).to permit(admin.account, status)
+    end
+
+    it 'grants access if owner' do
+      expect(subject).to permit(status.account, status)
+    end
+
+    it 'denies access unless staff' do
+      expect(subject).to_not permit(bob, status)
+    end
+  end
 end
diff --git a/spec/services/activitypub/fetch_replies_service_spec.rb b/spec/services/activitypub/fetch_replies_service_spec.rb
index 65c453341..fe49b18c1 100644
--- a/spec/services/activitypub/fetch_replies_service_spec.rb
+++ b/spec/services/activitypub/fetch_replies_service_spec.rb
@@ -34,9 +34,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
     context 'when the payload is a Collection with inlined replies' do
       context 'when passing the collection itself' do
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, payload)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
 
@@ -46,9 +45,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
         end
 
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, collection_uri)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
     end
@@ -65,9 +63,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
 
       context 'when passing the collection itself' do
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, payload)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
 
@@ -77,9 +74,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
         end
 
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, collection_uri)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
     end
@@ -100,9 +96,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
 
       context 'when passing the collection itself' do
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, payload)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
 
@@ -112,9 +107,8 @@ RSpec.describe ActivityPub::FetchRepliesService, type: :service do
         end
 
         it 'spawns workers for up to 5 replies on the same server' do
-          allow(FetchReplyWorker).to receive(:push_bulk)
+          expect(FetchReplyWorker).to receive(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
           subject.call(status, collection_uri)
-          expect(FetchReplyWorker).to have_received(:push_bulk).with(['http://example.com/self-reply-1', 'http://example.com/self-reply-2', 'http://example.com/self-reply-3', 'http://example.com/self-reply-4', 'http://example.com/self-reply-5'])
         end
       end
     end
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
new file mode 100644
index 000000000..6ee1dcb43
--- /dev/null
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -0,0 +1,248 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
+  let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) }
+
+  let(:alice) { Fabricate(:account) }
+  let(:bob) { Fabricate(:account) }
+
+  let(:mentions) { [] }
+  let(:tags) { [] }
+  let(:media_attachments) { [] }
+
+  before do
+    mentions.each { |a| Fabricate(:mention, status: status, account: a) }
+    tags.each { |t| status.tags << t }
+    media_attachments.each { |m| status.media_attachments << m }
+  end
+
+  let(:payload) do
+    {
+      '@context': 'https://www.w3.org/ns/activitystreams',
+      id: 'foo',
+      type: 'Note',
+      summary: 'Show more',
+      content: 'Hello universe',
+      updated: '2021-09-08T22:39:25Z',
+      tag: [
+        { type: 'Hashtag', name: 'hoge' },
+        { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) },
+      ],
+    }
+  end
+
+  let(:json) { Oj.load(Oj.dump(payload)) }
+
+  subject { described_class.new }
+
+  describe '#call' do
+    it 'updates text' do
+      subject.call(status, json)
+      expect(status.reload.text).to eq 'Hello universe'
+    end
+
+    it 'updates content warning' do
+      subject.call(status, json)
+      expect(status.reload.spoiler_text).to eq 'Show more'
+    end
+
+    context 'originally without tags' do
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates tags' do
+        expect(status.tags.reload.map(&:name)).to eq %w(hoge)
+      end
+    end
+
+    context 'originally with tags' do
+      let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] }
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          summary: 'Show more',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          tag: [
+            { type: 'Hashtag', name: 'foo' },
+          ],
+        }
+      end
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates tags' do
+        expect(status.tags.reload.map(&:name)).to eq %w(foo)
+      end
+    end
+
+    context 'originally without mentions' do
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates mentions' do
+        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
+      end
+    end
+
+    context 'originally with mentions' do
+      let(:mentions) { [alice, bob] }
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'updates mentions' do
+        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
+      end
+    end
+
+    context 'originally without media attachments' do
+      before do
+        allow(RedownloadMediaWorker).to receive(:perform_async)
+        subject.call(status, json)
+      end
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          attachment: [
+            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png' },
+          ]
+        }
+      end
+
+      it 'updates media attachments' do
+        media_attachment = status.media_attachments.reload.first
+
+        expect(media_attachment).to_not be_nil
+        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
+      end
+
+      it 'queues download of media attachments' do
+        expect(RedownloadMediaWorker).to have_received(:perform_async)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally with media attachments' do
+      let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] }
+
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Note',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          attachment: [
+            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png', name: 'A picture' },
+          ]
+        }
+      end
+
+      before do
+        allow(RedownloadMediaWorker).to receive(:perform_async)
+        subject.call(status, json)
+      end
+
+      it 'updates the existing media attachment in-place' do
+        media_attachment = status.media_attachments.reload.first
+
+        expect(media_attachment).to_not be_nil
+        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
+        expect(media_attachment.description).to eq 'A picture'
+      end
+
+      it 'does not queue redownload for the existing media attachment' do
+        expect(RedownloadMediaWorker).to_not have_received(:perform_async)
+      end
+
+      it 'updates media attachments' do
+        expect(status.media_attachments.reload.map(&:remote_url)).to eq %w(https://example.com/foo.png)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally with a poll' do
+      before do
+        poll = Fabricate(:poll, status: status)
+        status.update(preloadable_poll: poll)
+        subject.call(status, json)
+      end
+
+      it 'removes poll' do
+        expect(status.reload.poll).to eq nil
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    context 'originally without a poll' do
+      let(:payload) do
+        {
+          '@context': 'https://www.w3.org/ns/activitystreams',
+          id: 'foo',
+          type: 'Question',
+          content: 'Hello universe',
+          updated: '2021-09-08T22:39:25Z',
+          closed: true,
+          oneOf: [
+            { type: 'Note', name: 'Foo' },
+            { type: 'Note', name: 'Bar' },
+            { type: 'Note', name: 'Baz' },
+          ],
+        }
+      end
+
+      before do
+        subject.call(status, json)
+      end
+
+      it 'creates a poll' do
+        poll = status.reload.poll
+
+        expect(poll).to_not be_nil
+        expect(poll.options).to eq %w(Foo Bar Baz)
+      end
+
+      it 'records media change in edit' do
+        expect(status.edits.reload.last.media_attachments_changed).to be true
+      end
+    end
+
+    it 'creates edit history' do
+      subject.call(status, json)
+      expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe']
+    end
+
+    it 'sets edited timestamp' do
+      subject.call(status, json)
+      expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
+    end
+
+    it 'records that no media has been changed in edit' do
+      subject.call(status, json)
+      expect(status.edits.reload.last.media_attachments_changed).to be false
+    end
+  end
+end
diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb
index fb7c6b462..482068d58 100644
--- a/spec/services/remove_status_service_spec.rb
+++ b/spec/services/remove_status_service_spec.rb
@@ -15,35 +15,97 @@ RSpec.describe RemoveStatusService, type: :service do
 
     jeff.follow!(alice)
     hank.follow!(alice)
-
-    @status = PostStatusService.new.call(alice, text: 'Hello @bob@example.com')
-    FavouriteService.new.call(jeff, @status)
-    Fabricate(:status, account: bill, reblog: @status, uri: 'hoge')
   end
 
-  it 'removes status from author\'s home feed' do
-    subject.call(@status)
-    expect(HomeFeed.new(alice).get(10)).to_not include(@status.id)
-  end
+  context 'when removed status is not a reblog' do
+    before do
+      @status = PostStatusService.new.call(alice, text: 'Hello @bob@example.com ThisIsASecret')
+      FavouriteService.new.call(jeff, @status)
+      Fabricate(:status, account: bill, reblog: @status, uri: 'hoge')
+    end
 
-  it 'removes status from local follower\'s home feed' do
-    subject.call(@status)
-    expect(HomeFeed.new(jeff).get(10)).to_not include(@status.id)
-  end
+    it 'removes status from author\'s home feed' do
+      subject.call(@status)
+      expect(HomeFeed.new(alice).get(10)).to_not include(@status.id)
+    end
+
+    it 'removes status from local follower\'s home feed' do
+      subject.call(@status)
+      expect(HomeFeed.new(jeff).get(10)).to_not include(@status.id)
+    end
 
-  it 'sends delete activity to followers' do
-    subject.call(@status)
-    expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice
+    it 'sends Delete activity to followers' do
+      subject.call(@status)
+      expect(a_request(:post, 'http://example.com/inbox').with(
+        body: hash_including({
+          'type' => 'Delete',
+          'object' => {
+            'type' => 'Tombstone',
+            'id' => ActivityPub::TagManager.instance.uri_for(@status),
+            'atomUri' => OStatus::TagManager.instance.uri_for(@status),
+          },
+        })
+      )).to have_been_made.once
+    end
+
+    it 'sends Delete activity to rebloggers' do
+      subject.call(@status)
+      expect(a_request(:post, 'http://example2.com/inbox').with(
+        body: hash_including({
+          'type' => 'Delete',
+          'object' => {
+            'type' => 'Tombstone',
+            'id' => ActivityPub::TagManager.instance.uri_for(@status),
+            'atomUri' => OStatus::TagManager.instance.uri_for(@status),
+          },
+        })
+      )).to have_been_made.once
+    end
+
+    it 'remove status from notifications' do
+      expect { subject.call(@status) }.to change {
+        Notification.where(activity_type: 'Favourite', from_account: jeff, account: alice).count
+      }.from(1).to(0)
+    end
   end
 
-  it 'sends delete activity to rebloggers' do
-    subject.call(@status)
-    expect(a_request(:post, 'http://example2.com/inbox')).to have_been_made
+  context 'when removed status is a private self-reblog' do
+    before do
+      @original_status = Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :private)
+      @status = ReblogService.new.call(alice, @original_status)
+    end
+
+    it 'sends Undo activity to followers' do
+      subject.call(@status)
+      expect(a_request(:post, 'http://example.com/inbox').with(
+        body: hash_including({
+          'type' => 'Undo',
+          'object' => hash_including({
+            'type' => 'Announce',
+            'object' => ActivityPub::TagManager.instance.uri_for(@original_status),
+          }),
+        })
+      )).to have_been_made.once
+    end
   end
 
-  it 'remove status from notifications' do
-    expect { subject.call(@status) }.to change {
-      Notification.where(activity_type: 'Favourite', from_account: jeff, account: alice).count
-    }.from(1).to(0)
+  context 'when removed status is public self-reblog' do
+    before do
+      @original_status = Fabricate(:status, account: alice, text: 'Hello ThisIsASecret', visibility: :public)
+      @status = ReblogService.new.call(alice, @original_status)
+    end
+
+    it 'sends Undo activity to followers' do
+      subject.call(@status)
+      expect(a_request(:post, 'http://example.com/inbox').with(
+        body: hash_including({
+          'type' => 'Undo',
+          'object' => hash_including({
+            'type' => 'Announce',
+            'object' => ActivityPub::TagManager.instance.uri_for(@original_status),
+          }),
+        })
+      )).to have_been_made.once
+    end
   end
 end
diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb
new file mode 100644
index 000000000..4fd4837c6
--- /dev/null
+++ b/spec/services/update_status_service_spec.rb
@@ -0,0 +1,140 @@
+require 'rails_helper'
+
+RSpec.describe UpdateStatusService, type: :service do
+  subject { described_class.new }
+
+  context 'when text changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let(:preview_card) { Fabricate(:preview_card) }
+
+    before do
+      status.preview_cards << preview_card
+      subject.call(status, status.account_id, text: 'Bar')
+    end
+
+    it 'updates text' do
+      expect(status.reload.text).to eq 'Bar'
+    end
+
+    it 'resets preview card' do
+      expect(status.reload.preview_card).to be_nil
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Bar', false]]
+    end
+  end
+
+  context 'when content warning changes' do
+    let!(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') }
+    let(:preview_card) { Fabricate(:preview_card) }
+
+    before do
+      status.preview_cards << preview_card
+      subject.call(status, status.account_id, text: 'Foo', spoiler_text: 'Bar')
+    end
+
+    it 'updates content warning' do
+      expect(status.reload.spoiler_text).to eq 'Bar'
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :spoiler_text, :media_attachments_changed)).to eq [['Foo', '', false], ['Foo', 'Bar', false]]
+    end
+  end
+
+  context 'when media attachments change' do
+    let!(:status) { Fabricate(:status, text: 'Foo') }
+    let!(:detached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
+    let!(:attached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
+
+    before do
+      status.media_attachments << detached_media_attachment
+      subject.call(status, status.account_id, text: 'Foo', media_ids: [attached_media_attachment.id])
+    end
+
+    it 'updates media attachments' do
+      expect(status.media_attachments.to_a).to eq [attached_media_attachment]
+    end
+
+    it 'detaches detached media attachments' do
+      expect(detached_media_attachment.reload.status_id).to be_nil
+    end
+
+    it 'attaches attached media attachments' do
+      expect(attached_media_attachment.reload.status_id).to eq status.id
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+    end
+  end
+
+  context 'when poll changes' do
+    let(:account) { Fabricate(:account) }
+    let!(:status) { Fabricate(:status, text: 'Foo', account: account, poll_attributes: {options: %w(Foo Bar), account: account, multiple: false, hide_totals: false, expires_at: 7.days.from_now }) }
+    let!(:poll)   { status.poll }
+    let!(:voter) { Fabricate(:account) }
+
+    before do
+      status.update(poll: poll)
+      VoteService.new.call(voter, poll, [0])
+      subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz Foo), expires_in: 5.days.to_i })
+    end
+
+    it 'updates poll' do
+      poll = status.poll.reload
+      expect(poll.options).to eq %w(Bar Baz Foo)
+    end
+
+    it 'resets votes' do
+      poll = status.poll.reload
+      expect(poll.votes_count).to eq 0
+      expect(poll.votes.count).to eq 0
+      expect(poll.cached_tallies).to eq [0, 0, 0]
+    end
+
+    it 'saves edit history' do
+      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
+    end
+  end
+
+  context 'when mentions in text change' do
+    let!(:account) { Fabricate(:account) }
+    let!(:alice) { Fabricate(:account, username: 'alice') }
+    let!(:bob) { Fabricate(:account, username: 'bob') }
+    let!(:status) { PostStatusService.new.call(account, text: 'Hello @alice') }
+
+    before do
+      subject.call(status, status.account_id, text: 'Hello @bob')
+    end
+
+    it 'changes mentions' do
+      expect(status.active_mentions.pluck(:account_id)).to eq [bob.id]
+    end
+
+    it 'keeps old mentions as silent mentions' do
+      expect(status.mentions.pluck(:account_id)).to match_array([alice.id, bob.id])
+    end
+  end
+
+  context 'when hashtags in text change' do
+    let!(:account) { Fabricate(:account) }
+    let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') }
+
+    before do
+      subject.call(status, status.account_id, text: 'Hello #bar')
+    end
+
+    it 'changes tags' do
+      expect(status.tags.pluck(:name)).to eq %w(bar)
+    end
+  end
+
+  it 'notifies ActivityPub about the update' do
+    status = Fabricate(:status, text: 'Foo')
+    allow(ActivityPub::DistributionWorker).to receive(:perform_async)
+    subject.call(status, status.account_id, text: 'Bar')
+    expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index b6d127a08..0414ba9ed 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -57,3 +57,10 @@ end
 def json_str_to_hash(str)
   JSON.parse(str, symbolize_names: true)
 end
+
+def expect_push_bulk_to_match(klass, matcher)
+  expect(Sidekiq::Client).to receive(:push_bulk).with(hash_including({
+    "class" => klass,
+    "args" => matcher
+  }))
+end
diff --git a/spec/workers/activitypub/distribute_poll_update_worker_spec.rb b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
index 7eb6119fd..d68a695b7 100644
--- a/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
+++ b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
@@ -10,13 +10,12 @@ describe ActivityPub::DistributePollUpdateWorker do
 
   describe '#perform' do
     before do
-      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
       follower.follow!(account)
     end
 
     it 'delivers to followers' do
+      expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), account.id, 'http://example.com']])
       subject.perform(status.id)
-      expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
     end
   end
 end
diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb
index c017b4da1..3a5900d9b 100644
--- a/spec/workers/activitypub/distribution_worker_spec.rb
+++ b/spec/workers/activitypub/distribution_worker_spec.rb
@@ -8,7 +8,6 @@ describe ActivityPub::DistributionWorker do
 
   describe '#perform' do
     before do
-      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
       follower.follow!(status.account)
     end
 
@@ -18,8 +17,8 @@ describe ActivityPub::DistributionWorker do
       end
 
       it 'delivers to followers' do
+        expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), status.account.id, 'http://example.com', anything]])
         subject.perform(status.id)
-        expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
       end
     end
 
@@ -29,8 +28,8 @@ describe ActivityPub::DistributionWorker do
       end
 
       it 'delivers to followers' do
+        expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), status.account.id, 'http://example.com', anything]])
         subject.perform(status.id)
-        expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
       end
     end
 
@@ -43,8 +42,8 @@ describe ActivityPub::DistributionWorker do
       end
 
       it 'delivers to mentioned accounts' do
+        expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), status.account.id, 'https://foo.bar/inbox', anything]])
         subject.perform(status.id)
-        expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['https://foo.bar/inbox'])
       end
     end
   end
diff --git a/spec/workers/activitypub/move_distribution_worker_spec.rb b/spec/workers/activitypub/move_distribution_worker_spec.rb
index b52788e54..af8c44cc0 100644
--- a/spec/workers/activitypub/move_distribution_worker_spec.rb
+++ b/spec/workers/activitypub/move_distribution_worker_spec.rb
@@ -9,14 +9,16 @@ describe ActivityPub::MoveDistributionWorker do
 
   describe '#perform' do
     before do
-      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
       follower.follow!(migration.account)
       blocker.block!(migration.account)
     end
 
     it 'delivers to followers and known blockers' do
+      expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [
+        [kind_of(String), migration.account.id, 'http://example.com'],
+        [kind_of(String), migration.account.id, 'http://example2.com']
+      ])
       subject.perform(migration.id)
-        expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com', 'http://example2.com'])
     end
   end
 end
diff --git a/spec/workers/activitypub/status_update_distribution_worker_spec.rb b/spec/workers/activitypub/status_update_distribution_worker_spec.rb
new file mode 100644
index 000000000..c014c6790
--- /dev/null
+++ b/spec/workers/activitypub/status_update_distribution_worker_spec.rb
@@ -0,0 +1,44 @@
+require 'rails_helper'
+
+describe ActivityPub::StatusUpdateDistributionWorker do
+  subject { described_class.new }
+
+  let(:status)   { Fabricate(:status, text: 'foo') }
+  let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+
+  describe '#perform' do
+    before do
+      follower.follow!(status.account)
+
+      status.snapshot!
+      status.text = 'bar'
+      status.edited_at = Time.now.utc
+      status.snapshot!
+      status.save!
+    end
+
+    context 'with public status' do
+      before do
+        status.update(visibility: :public)
+      end
+
+      it 'delivers to followers' do
+        expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), status.account.id, 'http://example.com', anything]])
+
+        subject.perform(status.id)
+      end
+    end
+
+    context 'with private status' do
+      before do
+        status.update(visibility: :private)
+      end
+
+      it 'delivers to followers' do
+        expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), status.account.id, 'http://example.com', anything]])
+
+        subject.perform(status.id)
+      end
+    end
+  end
+end
diff --git a/spec/workers/activitypub/update_distribution_worker_spec.rb b/spec/workers/activitypub/update_distribution_worker_spec.rb
index 688a424d5..0e057fd0b 100644
--- a/spec/workers/activitypub/update_distribution_worker_spec.rb
+++ b/spec/workers/activitypub/update_distribution_worker_spec.rb
@@ -8,13 +8,12 @@ describe ActivityPub::UpdateDistributionWorker do
 
   describe '#perform' do
     before do
-      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
       follower.follow!(account)
     end
 
     it 'delivers to followers' do
+      expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[kind_of(String), account.id, 'http://example.com', anything]])
       subject.perform(account.id)
-      expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
     end
   end
 end
diff --git a/spec/workers/move_worker_spec.rb b/spec/workers/move_worker_spec.rb
index 4db5810f1..be02d3192 100644
--- a/spec/workers/move_worker_spec.rb
+++ b/spec/workers/move_worker_spec.rb
@@ -21,7 +21,6 @@ describe MoveWorker do
     blocking_account.block!(source_account)
     muting_account.mute!(source_account)
 
-    allow(UnfollowFollowWorker).to receive(:push_bulk)
     allow(BlockService).to receive(:new).and_return(block_service)
     allow(block_service).to receive(:call)
   end
@@ -78,8 +77,8 @@ describe MoveWorker do
   context 'both accounts are distant' do
     describe 'perform' do
       it 'calls UnfollowFollowWorker' do
+        expect_push_bulk_to_match(UnfollowFollowWorker, [[local_follower.id, source_account.id, target_account.id, false]])
         subject.perform(source_account.id, target_account.id)
-        expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
       end
 
       include_examples 'user note handling'
@@ -92,8 +91,8 @@ describe MoveWorker do
 
     describe 'perform' do
       it 'calls UnfollowFollowWorker' do
+        expect_push_bulk_to_match(UnfollowFollowWorker, [[local_follower.id, source_account.id, target_account.id, true]])
         subject.perform(source_account.id, target_account.id)
-        expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
       end
 
       include_examples 'user note handling'