about summary refs log tree commit diff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/controllers/api/proofs_controller_spec.rb5
-rw-r--r--spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb27
-rw-r--r--spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb27
-rw-r--r--spec/controllers/api/v1/announcements/reactions_controller_spec.rb65
-rw-r--r--spec/controllers/api/v1/announcements_controller_spec.rb59
-rw-r--r--spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb20
-rw-r--r--spec/controllers/api/v1/trends_controller_spec.rb18
-rw-r--r--spec/controllers/concerns/obfuscate_filename_spec.rb30
-rw-r--r--spec/controllers/follower_accounts_controller_spec.rb12
-rw-r--r--spec/controllers/following_accounts_controller_spec.rb12
-rw-r--r--spec/fabricators/announcement_fabricator.rb6
-rw-r--r--spec/fabricators/announcement_mute_fabricator.rb4
-rw-r--r--spec/fabricators/announcement_reaction_fabricator.rb5
-rw-r--r--spec/fabricators/media_attachment_fabricator.rb18
-rw-r--r--spec/features/log_in_spec.rb36
-rw-r--r--spec/features/profile_spec.rb53
-rw-r--r--spec/lib/formatter_spec.rb24
-rw-r--r--spec/models/account_spec.rb14
-rw-r--r--spec/models/announcement_mute_spec.rb4
-rw-r--r--spec/models/announcement_reaction_spec.rb4
-rw-r--r--spec/models/announcement_spec.rb4
-rw-r--r--spec/models/domain_block_spec.rb10
-rw-r--r--spec/models/media_attachment_spec.rb31
-rw-r--r--spec/models/status_spec.rb12
-rw-r--r--spec/models/user_spec.rb15
-rw-r--r--spec/services/post_status_service_spec.rb8
-rw-r--r--spec/services/process_mentions_service_spec.rb46
-rw-r--r--spec/support/examples/models/concerns/account_avatar.rb20
-rw-r--r--spec/support/examples/models/concerns/account_header.rb23
-rw-r--r--spec/support/stories/profile_stories.rb45
-rw-r--r--spec/validators/reaction_validator_spec.rb42
-rw-r--r--spec/validators/unique_username_validator_spec.rb51
-rw-r--r--spec/workers/refollow_worker_spec.rb30
34 files changed, 669 insertions, 131 deletions
diff --git a/spec/controllers/api/proofs_controller_spec.rb b/spec/controllers/api/proofs_controller_spec.rb
index dbde4927f..2fe615005 100644
--- a/spec/controllers/api/proofs_controller_spec.rb
+++ b/spec/controllers/api/proofs_controller_spec.rb
@@ -85,10 +85,7 @@ describe Api::ProofsController do
         end
 
         it 'has the correct avatar url' do
-          first_part = 'https://cb6e6126.ngrok.io/system/accounts/avatars/'
-          last_part  = 'original/avatar.gif'
-
-          expect(body_as_json[:avatar]).to match /#{Regexp.quote(first_part)}(?:\d{3,5}\/){3}#{Regexp.quote(last_part)}/
+          expect(body_as_json[:avatar]).to match "https://cb6e6126.ngrok.io#{alice.avatar.url}"
         end
       end
     end
diff --git a/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb b/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb
index 75e0570e9..54587187f 100644
--- a/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/follower_accounts_controller_spec.rb
@@ -3,19 +3,38 @@ require 'rails_helper'
 describe Api::V1::Accounts::FollowerAccountsController do
   render_views
 
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
+  let(:user)    { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
+  let(:account) { Fabricate(:account) }
+  let(:alice)   { Fabricate(:account) }
+  let(:bob)     { Fabricate(:account) }
 
   before do
-    Fabricate(:follow, target_account: user.account)
+    alice.follow!(account)
+    bob.follow!(account)
     allow(controller).to receive(:doorkeeper_token) { token }
   end
 
   describe 'GET #index' do
     it 'returns http success' do
-      get :index, params: { account_id: user.account.id, limit: 1 }
+      get :index, params: { account_id: account.id, limit: 2 }
 
       expect(response).to have_http_status(200)
     end
+
+    it 'returns accounts following the given account' do
+      get :index, params: { account_id: account.id, limit: 2 }
+
+      expect(body_as_json.size).to eq 2
+      expect([body_as_json[0][:id], body_as_json[1][:id]]).to match_array([alice.id.to_s, bob.id.to_s])
+    end
+
+    it 'does not return blocked users' do
+      user.account.block!(bob)
+      get :index, params: { account_id: account.id, limit: 2 }
+
+      expect(body_as_json.size).to eq 1
+      expect(body_as_json[0][:id]).to eq alice.id.to_s
+    end
   end
 end
diff --git a/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb b/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb
index 7f7105ad3..a580a7368 100644
--- a/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/following_accounts_controller_spec.rb
@@ -3,19 +3,38 @@ require 'rails_helper'
 describe Api::V1::Accounts::FollowingAccountsController do
   render_views
 
-  let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
-  let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
+  let(:user)    { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
+  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts') }
+  let(:account) { Fabricate(:account) }
+  let(:alice)   { Fabricate(:account) }
+  let(:bob)     { Fabricate(:account) }
 
   before do
-    Fabricate(:follow, account: user.account)
+    account.follow!(alice)
+    account.follow!(bob)
     allow(controller).to receive(:doorkeeper_token) { token }
   end
 
   describe 'GET #index' do
     it 'returns http success' do
-      get :index, params: { account_id: user.account.id, limit: 1 }
+      get :index, params: { account_id: account.id, limit: 2 }
 
       expect(response).to have_http_status(200)
     end
+
+    it 'returns accounts followed by the given account' do
+      get :index, params: { account_id: account.id, limit: 2 }
+
+      expect(body_as_json.size).to eq 2
+      expect([body_as_json[0][:id], body_as_json[1][:id]]).to match_array([alice.id.to_s, bob.id.to_s])
+    end
+
+    it 'does not return blocked users' do
+      user.account.block!(bob)
+      get :index, params: { account_id: account.id, limit: 2 }
+
+      expect(body_as_json.size).to eq 1
+      expect(body_as_json[0][:id]).to eq alice.id.to_s
+    end
   end
 end
diff --git a/spec/controllers/api/v1/announcements/reactions_controller_spec.rb b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
new file mode 100644
index 000000000..72620e242
--- /dev/null
+++ b/spec/controllers/api/v1/announcements/reactions_controller_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::Announcements::ReactionsController, type: :controller do
+  render_views
+
+  let(:user)   { Fabricate(:user) }
+  let(:scopes) { 'write:favourites' }
+  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+  let!(:announcement) { Fabricate(:announcement) }
+
+  describe 'PUT #update' do
+    context 'without token' do
+      it 'returns http unauthorized' do
+        put :update, params: { announcement_id: announcement.id, id: 'πŸ˜‚' }
+        expect(response).to have_http_status :unauthorized
+      end
+    end
+
+    context 'with token' do
+      before do
+        allow(controller).to receive(:doorkeeper_token) { token }
+        put :update, params: { announcement_id: announcement.id, id: 'πŸ˜‚' }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'creates reaction' do
+        expect(announcement.announcement_reactions.find_by(name: 'πŸ˜‚', account: user.account)).to_not be_nil
+      end
+    end
+  end
+
+  describe 'DELETE #destroy' do
+    before do
+      announcement.announcement_reactions.create!(account: user.account, name: 'πŸ˜‚')
+    end
+
+    context 'without token' do
+      it 'returns http unauthorized' do
+        delete :destroy, params: { announcement_id: announcement.id, id: 'πŸ˜‚' }
+        expect(response).to have_http_status :unauthorized
+      end
+    end
+
+    context 'with token' do
+      before do
+        allow(controller).to receive(:doorkeeper_token) { token }
+        delete :destroy, params: { announcement_id: announcement.id, id: 'πŸ˜‚' }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'creates reaction' do
+        expect(announcement.announcement_reactions.find_by(name: 'πŸ˜‚', account: user.account)).to be_nil
+      end
+    end
+  end
+end
diff --git a/spec/controllers/api/v1/announcements_controller_spec.rb b/spec/controllers/api/v1/announcements_controller_spec.rb
new file mode 100644
index 000000000..6ee46b60e
--- /dev/null
+++ b/spec/controllers/api/v1/announcements_controller_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::AnnouncementsController, type: :controller do
+  render_views
+
+  let(:user)   { Fabricate(:user) }
+  let(:scopes) { 'read' }
+  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
+
+  let!(:announcement) { Fabricate(:announcement) }
+
+  describe 'GET #index' do
+    context 'without token' do
+      it 'returns http unprocessable entity' do
+        get :index
+        expect(response).to have_http_status :unprocessable_entity
+      end
+    end
+
+    context 'with token' do
+      before do
+        allow(controller).to receive(:doorkeeper_token) { token }
+        get :index
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+    end
+  end
+
+  describe 'POST #dismiss' do
+    context 'without token' do
+      it 'returns http unauthorized' do
+        post :dismiss, params: { id: announcement.id }
+        expect(response).to have_http_status :unauthorized
+      end
+    end
+
+    context 'with token' do
+      let(:scopes) { 'write:accounts' }
+
+      before do
+        allow(controller).to receive(:doorkeeper_token) { token }
+        post :dismiss, params: { id: announcement.id }
+      end
+
+      it 'returns http success' do
+        expect(response).to have_http_status(200)
+      end
+
+      it 'dismisses announcement' do
+        expect(announcement.announcement_mutes.find_by(account: user.account)).to_not be_nil
+      end
+    end
+  end
+end
diff --git a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
index 40f75c700..f053ae573 100644
--- a/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/favourited_by_accounts_controller_spec.rb
@@ -6,6 +6,8 @@ RSpec.describe Api::V1::Statuses::FavouritedByAccountsController, type: :control
   let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
   let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') }
+  let(:alice) { Fabricate(:account) }
+  let(:bob)   { Fabricate(:account) }
 
   context 'with an oauth token' do
     before do
@@ -16,14 +18,28 @@ RSpec.describe Api::V1::Statuses::FavouritedByAccountsController, type: :control
       let(:status) { Fabricate(:status, account: user.account) }
 
       before do
-        Fabricate(:favourite, status: status)
+        Favourite.create!(account: alice, status: status)
+        Favourite.create!(account: bob, status: status)
       end
 
       it 'returns http success' do
-        get :index, params: { status_id: status.id, limit: 1 }
+        get :index, params: { status_id: status.id, limit: 2 }
         expect(response).to have_http_status(200)
         expect(response.headers['Link'].links.size).to eq(2)
       end
+
+      it 'returns accounts who favorited the status' do
+        get :index, params: { status_id: status.id, limit: 2 }
+        expect(body_as_json.size).to eq 2
+      expect([body_as_json[0][:id], body_as_json[1][:id]]).to match_array([alice.id.to_s, bob.id.to_s])
+      end
+
+      it 'does not return blocked users' do
+        user.account.block!(bob)
+        get :index, params: { status_id: status.id, limit: 2 }
+        expect(body_as_json.size).to eq 1
+        expect(body_as_json[0][:id]).to eq alice.id.to_s
+      end
     end
   end
 
diff --git a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
index d758786dc..60908b7b3 100644
--- a/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
+++ b/spec/controllers/api/v1/statuses/reblogged_by_accounts_controller_spec.rb
@@ -6,6 +6,8 @@ RSpec.describe Api::V1::Statuses::RebloggedByAccountsController, type: :controll
   let(:user)  { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
   let(:app)   { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') }
   let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') }
+  let(:alice) { Fabricate(:account) }
+  let(:bob)   { Fabricate(:account) }
 
   context 'with an oauth token' do
     before do
@@ -16,14 +18,28 @@ RSpec.describe Api::V1::Statuses::RebloggedByAccountsController, type: :controll
       let(:status) { Fabricate(:status, account: user.account) }
 
       before do
-        Fabricate(:status, reblog_of_id: status.id)
+        Fabricate(:status, account: alice, reblog_of_id: status.id)
+        Fabricate(:status, account: bob, reblog_of_id: status.id)
       end
 
       it 'returns http success' do
-        get :index, params: { status_id: status.id, limit: 1 }
+        get :index, params: { status_id: status.id, limit: 2 }
         expect(response).to have_http_status(200)
         expect(response.headers['Link'].links.size).to eq(2)
       end
+
+      it 'returns accounts who reblogged the status' do
+        get :index, params: { status_id: status.id, limit: 2 }
+        expect(body_as_json.size).to eq 2
+      expect([body_as_json[0][:id], body_as_json[1][:id]]).to match_array([alice.id.to_s, bob.id.to_s])
+      end
+
+      it 'does not return blocked users' do
+        user.account.block!(bob)
+        get :index, params: { status_id: status.id, limit: 2 }
+        expect(body_as_json.size).to eq 1
+        expect(body_as_json[0][:id]).to eq alice.id.to_s
+      end
     end
   end
 
diff --git a/spec/controllers/api/v1/trends_controller_spec.rb b/spec/controllers/api/v1/trends_controller_spec.rb
new file mode 100644
index 000000000..91e0d18fe
--- /dev/null
+++ b/spec/controllers/api/v1/trends_controller_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Api::V1::TrendsController, type: :controller do
+  render_views
+
+  describe 'GET #index' do
+    before do
+      allow(TrendingTags).to receive(:get).and_return(Fabricate.times(10, :tag))
+      get :index
+    end
+
+    it 'returns http success' do
+      expect(response).to have_http_status(200)
+    end
+  end
+end
diff --git a/spec/controllers/concerns/obfuscate_filename_spec.rb b/spec/controllers/concerns/obfuscate_filename_spec.rb
deleted file mode 100644
index e06d53c03..000000000
--- a/spec/controllers/concerns/obfuscate_filename_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe ApplicationController, type: :controller do
-  controller do
-    include ObfuscateFilename
-
-    obfuscate_filename :file
-
-    def file
-      render plain: params[:file]&.original_filename
-    end
-  end
-
-  before do
-    routes.draw { get 'file' => 'anonymous#file' }
-  end
-
-  it 'obfusticates filename if the given parameter is specified' do
-    file = fixture_file_upload('files/imports.txt', 'text/plain')
-    post 'file', params: { file: file }
-    expect(response.body).to end_with '.txt'
-    expect(response.body).not_to include 'imports'
-  end
-
-  it 'does nothing if the given parameter is not specified' do
-    post 'file'
-  end
-end
diff --git a/spec/controllers/follower_accounts_controller_spec.rb b/spec/controllers/follower_accounts_controller_spec.rb
index 83032ab64..34a0cf3f4 100644
--- a/spec/controllers/follower_accounts_controller_spec.rb
+++ b/spec/controllers/follower_accounts_controller_spec.rb
@@ -22,6 +22,18 @@ describe FollowerAccountsController do
         expect(assigned[0]).to eq follow1
         expect(assigned[1]).to eq follow0
       end
+
+      it 'does not assign blocked users' do
+        user = Fabricate(:user)
+        user.account.block!(follower0)
+        sign_in(user)
+
+        expect(response).to have_http_status(200)
+
+        assigned = assigns(:follows).to_a
+        expect(assigned.size).to eq 1
+        expect(assigned[0]).to eq follow1
+      end
     end
 
     context 'when format is json' do
diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb
index d5e4ee587..e9a1f597d 100644
--- a/spec/controllers/following_accounts_controller_spec.rb
+++ b/spec/controllers/following_accounts_controller_spec.rb
@@ -22,6 +22,18 @@ describe FollowingAccountsController do
         expect(assigned[0]).to eq follow1
         expect(assigned[1]).to eq follow0
       end
+
+      it 'does not assign blocked users' do
+        user = Fabricate(:user)
+        user.account.block!(followee0)
+        sign_in(user)
+
+        expect(response).to have_http_status(200)
+
+        assigned = assigns(:follows).to_a
+        expect(assigned.size).to eq 1
+        expect(assigned[0]).to eq follow1
+      end
     end
 
     context 'when format is json' do
diff --git a/spec/fabricators/announcement_fabricator.rb b/spec/fabricators/announcement_fabricator.rb
new file mode 100644
index 000000000..5a3871d90
--- /dev/null
+++ b/spec/fabricators/announcement_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:announcement) do
+  text      { Faker::Lorem.paragraph(sentence_count: 2) }
+  published true
+  starts_at nil
+  ends_at   nil
+end
diff --git a/spec/fabricators/announcement_mute_fabricator.rb b/spec/fabricators/announcement_mute_fabricator.rb
new file mode 100644
index 000000000..c4eafe8f4
--- /dev/null
+++ b/spec/fabricators/announcement_mute_fabricator.rb
@@ -0,0 +1,4 @@
+Fabricator(:announcement_mute) do
+  account
+  announcement
+end
diff --git a/spec/fabricators/announcement_reaction_fabricator.rb b/spec/fabricators/announcement_reaction_fabricator.rb
new file mode 100644
index 000000000..f923c59c6
--- /dev/null
+++ b/spec/fabricators/announcement_reaction_fabricator.rb
@@ -0,0 +1,5 @@
+Fabricator(:announcement_reaction) do
+  account
+  announcement
+  name '🌿'
+end
diff --git a/spec/fabricators/media_attachment_fabricator.rb b/spec/fabricators/media_attachment_fabricator.rb
index bb938e36d..651927c2d 100644
--- a/spec/fabricators/media_attachment_fabricator.rb
+++ b/spec/fabricators/media_attachment_fabricator.rb
@@ -1,16 +1,12 @@
 Fabricator(:media_attachment) do
   account
+
   file do |attrs|
-    [
-      case attrs[:type]
-      when :gifv
-        attachment_fixture ['attachment.gif', 'attachment.webm'].sample
-      when :image
-        attachment_fixture 'attachment.jpg'
-      when nil
-        attachment_fixture ['attachment.gif', 'attachment.jpg', 'attachment.webm'].sample
-      end,
-      nil
-    ].sample
+    case attrs[:type]
+    when :gifv, :video
+      attachment_fixture('attachment.webm')
+    else
+      attachment_fixture('attachment.jpg')
+    end
   end
 end
diff --git a/spec/features/log_in_spec.rb b/spec/features/log_in_spec.rb
index b874c255b..de1a6de03 100644
--- a/spec/features/log_in_spec.rb
+++ b/spec/features/log_in_spec.rb
@@ -1,47 +1,51 @@
-require "rails_helper"
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+feature 'Log in' do
+  include ProfileStories
 
-feature "Log in" do
   given(:email)        { "test@example.com" }
   given(:password)     { "password" }
   given(:confirmed_at) { Time.zone.now }
 
   background do
-    Fabricate(:user, email: email, password: password, confirmed_at: confirmed_at)
+    as_a_registered_user
     visit new_user_session_path
   end
 
   subject { page }
 
-  scenario "A valid email and password user is able to log in" do
-    fill_in "user_email", with: email
-    fill_in "user_password", with: password
+  scenario 'A valid email and password user is able to log in' do
+    fill_in 'user_email', with: email
+    fill_in 'user_password', with: password
     click_on I18n.t('auth.login')
 
-    is_expected.to have_css("div.app-holder")
+    is_expected.to have_css('div.app-holder')
   end
 
-  scenario "A invalid email and password user is not able to log in" do
-    fill_in "user_email", with: "invalid_email"
-    fill_in "user_password", with: "invalid_password"
+  scenario 'A invalid email and password user is not able to log in' do
+    fill_in 'user_email', with: 'invalid_email'
+    fill_in 'user_password', with: 'invalid_password'
     click_on I18n.t('auth.login')
 
-    is_expected.to have_css(".flash-message", text: failure_message("invalid"))
+    is_expected.to have_css('.flash-message', text: failure_message('invalid'))
   end
 
   context do
     given(:confirmed_at) { nil }
 
-    scenario "A unconfirmed user is able to log in" do
-      fill_in "user_email", with: email
-      fill_in "user_password", with: password
+    scenario 'A unconfirmed user is able to log in' do
+      fill_in 'user_email', with: email
+      fill_in 'user_password', with: password
       click_on I18n.t('auth.login')
 
-      is_expected.to have_css("div.admin-wrapper")
+      is_expected.to have_css('div.admin-wrapper')
     end
   end
 
   def failure_message(message)
     keys = User.authentication_keys.map { |key| User.human_attribute_name(key) }
-    I18n.t("devise.failure.#{message}", authentication_keys: keys.join("support.array.words_connector"))
+    I18n.t("devise.failure.#{message}", authentication_keys: keys.join('support.array.words_connector'))
   end
 end
diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb
new file mode 100644
index 000000000..3202167ca
--- /dev/null
+++ b/spec/features/profile_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+feature 'Profile' do
+  include ProfileStories
+
+  given(:local_domain) { ENV['LOCAL_DOMAIN'] }
+
+  background do
+    as_a_logged_in_user
+    with_alice_as_local_user
+  end
+
+  subject { page }
+
+  scenario 'I can view Annes public account' do
+    visit account_path('alice')
+
+    is_expected.to have_title("alice (@alice@#{local_domain})")
+
+    within('.public-account-header h1') do
+      is_expected.to have_content("alice @alice@#{local_domain}")
+    end
+
+    bio_elem = first('.public-account-bio')
+    expect(bio_elem).to have_content(alice_bio)
+    # The bio has hashtags made clickable
+    expect(bio_elem).to have_link('cryptology')
+    expect(bio_elem).to have_link('science')
+    # Nicknames are make clickable
+    expect(bio_elem).to have_link('@alice')
+    expect(bio_elem).to have_link('@bob')
+    # Nicknames not on server are not clickable
+    expect(bio_elem).not_to have_link('@pepe')
+  end
+
+  scenario 'I can change my account' do
+    visit settings_profile_path
+    fill_in 'Display name', with: 'Bob'
+    fill_in 'Bio', with: 'Bob is silent'
+    click_on 'Save changes'
+    is_expected.to have_content 'Changes successfully saved!'
+
+    # View my own public profile and see the changes
+    click_link "Bob @bob@#{local_domain}"
+
+    within('.public-account-header h1') do
+      is_expected.to have_content("Bob @bob@#{local_domain}")
+    end
+    expect(first('.public-account-bio')).to have_content('Bob is silent')
+  end
+end
diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb
index b8108a247..633d59c2a 100644
--- a/spec/lib/formatter_spec.rb
+++ b/spec/lib/formatter_spec.rb
@@ -242,6 +242,30 @@ RSpec.describe Formatter do
         is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagγ‚Ώγ‚°</span></a>'
       end
     end
+
+    context 'given a stand-alone xmpp: URI' do
+      let(:text) { 'xmpp:user@instance.com' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="xmpp:user@instance.com"'
+      end
+    end
+
+    context 'given a an xmpp: URI with a query-string' do
+      let(:text) { 'please join xmpp:muc@instance.com?join right now' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="xmpp:muc@instance.com?join"'
+      end
+    end
+
+    context 'given text containing a magnet: URI' do
+      let(:text) { 'wikipedia gives this example of a magnet uri: magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a' }
+
+      it 'matches the full URI' do
+        is_expected.to include 'href="magnet:?xt=urn:btih:c12fe1c06bba254a9dc9f519b335aa7c1367a88a"'
+      end
+    end
   end
 
   describe '#format_spoiler' do
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index b2f6234cb..98d29e6f3 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -215,13 +215,6 @@ RSpec.describe Account, type: :model do
     end
   end
 
-  describe '#subscription' do
-    it 'returns an OStatus subscription' do
-      account = Fabricate(:account)
-      expect(account.subscription('')).to be_instance_of OStatus2::Subscription
-    end
-  end
-
   describe '#object_type' do
     it 'is always a person' do
       account = Fabricate(:account)
@@ -626,18 +619,18 @@ RSpec.describe Account, type: :model do
     end
 
     context 'when is remote' do
-      it 'is invalid if the username is not unique in case-sensitive comparison among accounts in the same normalized domain' do
+      it 'is invalid if the username is same among accounts in the same normalized domain' do
         Fabricate(:account, domain: 'にゃん', username: 'username')
         account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'username')
         account.valid?
         expect(account).to model_have_error_on_field(:username)
       end
 
-      it 'is valid even if the username is unique only in case-sensitive comparison among accounts in the same normalized domain' do
+      it 'is invalid if the username is not unique in case-insensitive comparison among accounts in the same normalized domain' do
         Fabricate(:account, domain: 'にゃん', username: 'username')
         account = Fabricate.build(:account, domain: 'xn--r9j5b5b', username: 'Username')
         account.valid?
-        expect(account).not_to model_have_error_on_field(:username)
+        expect(account).to model_have_error_on_field(:username)
       end
 
       it 'is valid even if the username contains hyphens' do
@@ -823,4 +816,5 @@ RSpec.describe Account, type: :model do
   end
 
   include_examples 'AccountAvatar', :account
+  include_examples 'AccountHeader', :account
 end
diff --git a/spec/models/announcement_mute_spec.rb b/spec/models/announcement_mute_spec.rb
new file mode 100644
index 000000000..9d0e4c903
--- /dev/null
+++ b/spec/models/announcement_mute_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AnnouncementMute, type: :model do
+end
diff --git a/spec/models/announcement_reaction_spec.rb b/spec/models/announcement_reaction_spec.rb
new file mode 100644
index 000000000..f6e151584
--- /dev/null
+++ b/spec/models/announcement_reaction_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe AnnouncementReaction, type: :model do
+end
diff --git a/spec/models/announcement_spec.rb b/spec/models/announcement_spec.rb
new file mode 100644
index 000000000..7f7b647a9
--- /dev/null
+++ b/spec/models/announcement_spec.rb
@@ -0,0 +1,4 @@
+require 'rails_helper'
+
+RSpec.describe Announcement, type: :model do
+end
diff --git a/spec/models/domain_block_spec.rb b/spec/models/domain_block_spec.rb
index d98c5e118..28647dc89 100644
--- a/spec/models/domain_block_spec.rb
+++ b/spec/models/domain_block_spec.rb
@@ -52,6 +52,16 @@ RSpec.describe DomainBlock, type: :model do
       block = Fabricate(:domain_block, domain: 'sub.example.com')
       expect(DomainBlock.rule_for('sub.example.com')).to eq block
     end
+
+    it 'returns a rule matching a blocked TLD' do
+      block = Fabricate(:domain_block, domain: 'google')
+      expect(DomainBlock.rule_for('google')).to eq block
+    end
+
+    it 'returns a rule matching a subdomain of a blocked TLD' do
+      block = Fabricate(:domain_block, domain: 'google')
+      expect(DomainBlock.rule_for('maps.google')).to eq block
+    end
   end
 
   describe '#stricter_than?' do
diff --git a/spec/models/media_attachment_spec.rb b/spec/models/media_attachment_spec.rb
index 7ddfba7ed..456bc4216 100644
--- a/spec/models/media_attachment_spec.rb
+++ b/spec/models/media_attachment_spec.rb
@@ -31,14 +31,6 @@ RSpec.describe MediaAttachment, type: :model do
     context 'file is blank' do
       let(:file) { nil }
 
-      context 'remote_url is blank' do
-        let(:remote_url) { '' }
-
-        it 'returns false' do
-          is_expected.to be false
-        end
-      end
-
       context 'remote_url is present' do
         let(:remote_url) { 'remote_url' }
 
@@ -133,6 +125,29 @@ RSpec.describe MediaAttachment, type: :model do
       expect(media.file.meta["small"]["height"]).to eq 327
       expect(media.file.meta["small"]["aspect"]).to eq 490.0 / 327
     end
+
+    it 'gives the file a random name' do
+      expect(media.file_file_name).to_not eq 'attachment.jpg'
+    end
+  end
+
+  describe 'base64-encoded jpeg' do
+    let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" }
+    let(:media) { MediaAttachment.create(account: Fabricate(:account), file: base64_attachment) }
+
+    it 'saves media attachment' do
+      expect(media.persisted?).to be true
+      expect(media.file).to_not be_nil
+    end
+
+    it 'gives the file a file name' do
+      expect(media.file_file_name).to_not be_blank
+    end
+  end
+
+  it 'is invalid without file' do
+    media = MediaAttachment.new(account: Fabricate(:account))
+    expect(media.valid?).to be false
   end
 
   describe 'descriptions for remote attachments' do
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 02f533287..38537da44 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -96,16 +96,20 @@ RSpec.describe Status, type: :model do
 
     context 'unless destroyed?' do
       context 'if reblog?' do
-        it 'returns "#{account.acct} shared a status by #{reblog.account.acct}"' do
+        it 'returns "#{account.acct} shared #{reblog.account.acct}\'s: #{preview}"' do
           reblog = subject.reblog = other
-          expect(subject.title).to eq "#{account.acct} shared a status by #{reblog.account.acct}"
+          preview = subject.text.slice(0, 10).split("\n")[0]
+          expect(subject.title).to(
+            eq "#{account.acct} shared #{reblog.account.acct}'s: #{preview}"
+          )
         end
       end
 
       context 'unless reblog?' do
-        it 'returns "New status by #{account.acct}"' do
+        it 'returns "#{account.acct}: #{preview}"' do
           subject.reblog = nil
-          expect(subject.title).to eq "New status by #{account.acct}"
+          preview = subject.text.slice(0, 20).split("\n")[0]
+          expect(subject.title).to eq "#{account.acct}: #{preview}"
         end
       end
     end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index d7c0b5359..5686ec909 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -322,20 +322,7 @@ RSpec.describe User, type: :model do
     end
 
     it 'disables user' do
-      expect(user).to have_attributes(disabled: true, current_sign_in_at: nil, last_sign_in_at: current_sign_in_at)
-    end
-  end
-
-  describe '#disable!' do
-    subject(:user) { Fabricate(:user, disabled: false, current_sign_in_at: current_sign_in_at, last_sign_in_at: nil) }
-    let(:current_sign_in_at) { Time.zone.now }
-
-    before do
-      user.disable!
-    end
-
-    it 'disables user' do
-      expect(user).to have_attributes(disabled: true, current_sign_in_at: nil, last_sign_in_at: current_sign_in_at)
+      expect(user).to have_attributes(disabled: true)
     end
   end
 
diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb
index bf06f50e9..025a3da40 100644
--- a/spec/services/post_status_service_spec.rb
+++ b/spec/services/post_status_service_spec.rb
@@ -212,14 +212,18 @@ RSpec.describe PostStatusService, type: :service do
 
   it 'does not allow attaching both videos and images' do
     account = Fabricate(:account)
+    video   = Fabricate(:media_attachment, type: :video, account: account)
+    image   = Fabricate(:media_attachment, type: :image, account: account)
+
+    video.update(type: :video)
 
     expect do
       subject.call(
         account,
         text: "test status update",
         media_ids: [
-          Fabricate(:media_attachment, type: :video, account: account),
-          Fabricate(:media_attachment, type: :image, account: account),
+          video,
+          image,
         ].map(&:id),
       )
     end.to raise_error(
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index b1abd79b0..c30de8eeb 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -5,11 +5,11 @@ RSpec.describe ProcessMentionsService, type: :service do
   let(:visibility) { :public }
   let(:status)     { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}", visibility: visibility) }
 
+  subject { ProcessMentionsService.new }
+
   context 'OStatus with public toot' do
     let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') }
 
-    subject { ProcessMentionsService.new }
-
     before do
       stub_request(:post, remote_user.salmon_url)
       subject.call(status)
@@ -24,8 +24,6 @@ RSpec.describe ProcessMentionsService, type: :service do
     let(:visibility)  { :private }
     let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') }
 
-    subject { ProcessMentionsService.new }
-
     before do
       stub_request(:post, remote_user.salmon_url)
       subject.call(status)
@@ -41,29 +39,45 @@ RSpec.describe ProcessMentionsService, type: :service do
   end
 
   context 'ActivityPub' do
-    let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
+    context do
+      let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
 
-    subject { ProcessMentionsService.new }
+      before do
+        stub_request(:post, remote_user.inbox_url)
+        subject.call(status)
+      end
 
-    before do
-      stub_request(:post, remote_user.inbox_url)
-      subject.call(status)
-    end
+      it 'creates a mention' do
+        expect(remote_user.mentions.where(status: status).count).to eq 1
+      end
 
-    it 'creates a mention' do
-      expect(remote_user.mentions.where(status: status).count).to eq 1
+      it 'sends activity to the inbox' do
+        expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once
+      end
     end
 
-    it 'sends activity to the inbox' do
-      expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once
+    context 'with an IDN domain' do
+      let(:remote_user) { Fabricate(:account, username: 'sneak', protocol: :activitypub, domain: 'xn--hresiar-mxa.ch', inbox_url: 'http://example.com/inbox') }
+      let(:status) { Fabricate(:status, account: account, text: "Hello @sneak@hæresiar.ch") }
+
+      before do
+        stub_request(:post, remote_user.inbox_url)
+        subject.call(status)
+      end
+
+      it 'creates a mention' do
+        expect(remote_user.mentions.where(status: status).count).to eq 1
+      end
+
+      it 'sends activity to the inbox' do
+        expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once
+      end
     end
   end
 
   context 'Temporarily-unreachable ActivityPub user' do
     let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox', last_webfingered_at: nil) }
 
-    subject { ProcessMentionsService.new }
-
     before do
       stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404)
       stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:remote_user@example.com").to_return(status: 500)
diff --git a/spec/support/examples/models/concerns/account_avatar.rb b/spec/support/examples/models/concerns/account_avatar.rb
index f2a8a2459..2180f5273 100644
--- a/spec/support/examples/models/concerns/account_avatar.rb
+++ b/spec/support/examples/models/concerns/account_avatar.rb
@@ -16,4 +16,24 @@ shared_examples 'AccountAvatar' do |fabricator|
       end
     end
   end
+
+  describe 'base64-encoded files' do
+    let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" }
+    let(:account) { Fabricate(fabricator, avatar: base64_attachment) }
+
+    it 'saves avatar' do
+      expect(account.persisted?).to be true
+      expect(account.avatar).to_not be_nil
+    end
+
+    it 'gives the avatar a file name' do
+      expect(account.avatar_file_name).to_not be_blank
+    end
+
+    it 'saves a new avatar under a different file name' do
+      previous_file_name = account.avatar_file_name
+      account.update(avatar: base64_attachment)
+      expect(account.avatar_file_name).to_not eq previous_file_name
+    end
+  end
 end
diff --git a/spec/support/examples/models/concerns/account_header.rb b/spec/support/examples/models/concerns/account_header.rb
new file mode 100644
index 000000000..77ee0e629
--- /dev/null
+++ b/spec/support/examples/models/concerns/account_header.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+shared_examples 'AccountHeader' do |fabricator|
+  describe 'base64-encoded files' do
+    let(:base64_attachment) { "data:image/jpeg;base64,#{Base64.encode64(attachment_fixture('attachment.jpg').read)}" }
+    let(:account) { Fabricate(fabricator, header: base64_attachment) }
+
+    it 'saves header' do
+      expect(account.persisted?).to be true
+      expect(account.header).to_not be_nil
+    end
+
+    it 'gives the header a file name' do
+      expect(account.header_file_name).to_not be_blank
+    end
+
+    it 'saves a new header under a different file name' do
+      previous_file_name = account.header_file_name
+      account.update(header: base64_attachment)
+      expect(account.header_file_name).to_not eq previous_file_name
+    end
+  end
+end
diff --git a/spec/support/stories/profile_stories.rb b/spec/support/stories/profile_stories.rb
new file mode 100644
index 000000000..75b413330
--- /dev/null
+++ b/spec/support/stories/profile_stories.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module ProfileStories
+  attr_reader :bob, :alice, :alice_bio
+
+  def as_a_registered_user
+    @bob = Fabricate(
+      :user,
+      email: email, password: password, confirmed_at: confirmed_at,
+      account: Fabricate(:account, username: 'bob')
+    )
+  end
+
+  def as_a_logged_in_user
+    as_a_registered_user
+    visit new_user_session_path
+    fill_in 'user_email', with: email
+    fill_in 'user_password', with: password
+    click_on I18n.t('auth.login')
+  end
+
+  def with_alice_as_local_user
+    @alice_bio = '@alice and @bob are fictional characters commonly used as'\
+                 'placeholder names in #cryptology, as well as #science and'\
+                 'engineering πŸ“– literature. Not affilated with @pepe.'
+
+    @alice = Fabricate(
+      :user,
+      email: 'alice@example.com', password: password, confirmed_at: confirmed_at,
+      account: Fabricate(:account, username: 'alice', note: @alice_bio)
+    )
+  end
+
+  def confirmed_at
+    @confirmed_at ||= Time.zone.now
+  end
+
+  def email
+    @email ||= 'test@example.com'
+  end
+
+  def password
+    @password ||= 'password'
+  end
+end
diff --git a/spec/validators/reaction_validator_spec.rb b/spec/validators/reaction_validator_spec.rb
new file mode 100644
index 000000000..d73104cb6
--- /dev/null
+++ b/spec/validators/reaction_validator_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ReactionValidator do
+  let(:announcement) { Fabricate(:announcement) }
+
+  describe '#validate' do
+    it 'adds error when not a valid unicode emoji' do
+      reaction = announcement.announcement_reactions.build(name: 'F')
+      subject.validate(reaction)
+      expect(reaction.errors).to_not be_empty
+    end
+
+    it 'does not add error when non-unicode emoji is a custom emoji' do
+      custom_emoji = Fabricate(:custom_emoji)
+      reaction = announcement.announcement_reactions.build(name: custom_emoji.shortcode, custom_emoji_id: custom_emoji.id)
+      subject.validate(reaction)
+      expect(reaction.errors).to be_empty
+    end
+
+    it 'adds error when 8 reactions already exist' do
+      %w(🐘 ❀️ πŸ™‰ 😍 πŸ˜‹ πŸ˜‚ 😞 πŸ‘).each do |name|
+        announcement.announcement_reactions.create!(name: name, account: Fabricate(:account))
+      end
+
+      reaction = announcement.announcement_reactions.build(name: '😘')
+      subject.validate(reaction)
+      expect(reaction.errors).to_not be_empty
+    end
+
+    it 'does not add error when new reaction is part of the existing ones' do
+      %w(🐘 ❀️ πŸ™‰ 😍 πŸ˜‹ πŸ˜‚ 😞 πŸ‘).each do |name|
+        announcement.announcement_reactions.create!(name: name, account: Fabricate(:account))
+      end
+
+      reaction = announcement.announcement_reactions.build(name: 'πŸ˜‹')
+      subject.validate(reaction)
+      expect(reaction.errors).to be_empty
+    end
+  end
+end
diff --git a/spec/validators/unique_username_validator_spec.rb b/spec/validators/unique_username_validator_spec.rb
index c2e2eedf4..6867cbc6c 100644
--- a/spec/validators/unique_username_validator_spec.rb
+++ b/spec/validators/unique_username_validator_spec.rb
@@ -4,22 +4,65 @@ require 'rails_helper'
 
 describe UniqueUsernameValidator do
   describe '#validate' do
+    context 'when local account' do
+      it 'does not add errors if username is nil' do
+        account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil))
+        subject.validate(account)
+        expect(account.errors).to_not have_received(:add)
+      end
+
+      it 'does not add errors when existing one is subject itself' do
+        account = Fabricate(:account, username: 'abcdef')
+        expect(account).to be_valid
+      end
+
+      it 'adds an error when the username is already used with ignoring cases' do
+        Fabricate(:account, username: 'ABCdef')
+        account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil))
+        subject.validate(account)
+        expect(account.errors).to have_received(:add)
+      end
+
+      it 'does not add errors when same username remote account exists' do
+        Fabricate(:account, username: 'abcdef', domain: 'example.com')
+        account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil))
+        subject.validate(account)
+        expect(account.errors).to_not have_received(:add)
+      end
+    end
+  end
+
+  context 'when remote account' do
     it 'does not add errors if username is nil' do
-      account = double(username: nil, persisted?: false, errors: double(add: nil))
+      account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil))
       subject.validate(account)
       expect(account.errors).to_not have_received(:add)
     end
 
     it 'does not add errors when existing one is subject itself' do
-      account = Fabricate(:account, username: 'abcdef')
+      account = Fabricate(:account, username: 'abcdef', domain: 'example.com')
       expect(account).to be_valid
     end
 
     it 'adds an error when the username is already used with ignoring cases' do
-      Fabricate(:account, username: 'ABCdef')
-      account = double(username: 'abcDEF', persisted?: false, errors: double(add: nil))
+      Fabricate(:account, username: 'ABCdef', domain: 'example.com')
+      account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil))
+      subject.validate(account)
+      expect(account.errors).to have_received(:add)
+    end
+
+    it 'adds an error when the domain is already used with ignoring cases' do
+      Fabricate(:account, username: 'ABCdef', domain: 'example.com')
+      account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil))
       subject.validate(account)
       expect(account.errors).to have_received(:add)
     end
+
+    it 'does not add errors when account with the same username and another domain exists' do
+      Fabricate(:account, username: 'abcdef', domain: 'example.com')
+      account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil))
+      subject.validate(account)
+      expect(account.errors).to_not have_received(:add)
+    end
   end
 end
diff --git a/spec/workers/refollow_worker_spec.rb b/spec/workers/refollow_worker_spec.rb
new file mode 100644
index 000000000..29771aa59
--- /dev/null
+++ b/spec/workers/refollow_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe RefollowWorker do
+  subject { described_class.new }
+  let(:account) { Fabricate(:account, domain: 'example.org', protocol: :activitypub) }
+  let(:alice)   { Fabricate(:account, domain: nil, username: 'alice') }
+  let(:bob)     { Fabricate(:account, domain: nil, username: 'bob') }
+
+  describe 'perform' do
+    let(:service) { double }
+
+    before do
+      allow(FollowService).to receive(:new).and_return(service)
+      allow(service).to receive(:call)
+
+      alice.follow!(account, reblogs: true)
+      bob.follow!(account, reblogs: false)
+    end
+
+    it 'calls FollowService for local followers' do
+      result = subject.perform(account.id)
+
+      expect(result).to be_nil
+      expect(service).to have_received(:call).with(alice, account, reblogs: true)
+      expect(service).to have_received(:call).with(bob, account, reblogs: false)
+    end
+  end
+end