catstodon/spec/models/account_spec.rb
Eugen Rochko da8fe8079e
Re-add follow recommendations API (#7918)
* Re-add follow recommendations API

    GET /api/v1/suggestions

Removed in 8efa081f21 due to Neo4J
dependency. The algorithm uses triadic closures, takes into account
suspensions, blocks, mutes, domain blocks, excludes locked and moved
accounts, and prefers more recently updated accounts.

* Track interactions with people you don't follow

Replying to, favouriting and reblogging someone you're not following
will make them show up in follow recommendations. The interactions
have different weights:

- Replying is 1
- Favouriting is 10 (decidedly positive interaction, but private)
- Reblogging is 20

Following them, muting or blocking will remove them from the list,
obviously.

* Remove triadic closures, ensure potential friendships are trimmed
2018-07-03 01:47:56 +02:00

799 lines
26 KiB
Ruby

require 'rails_helper'
RSpec.describe Account, type: :model do
context do
let(:bob) { Fabricate(:account, username: 'bob') }
subject { Fabricate(:account) }
describe '#follow!' do
it 'creates a follow' do
follow = subject.follow!(bob)
expect(follow).to be_instance_of Follow
expect(follow.account).to eq subject
expect(follow.target_account).to eq bob
end
end
describe '#unfollow!' do
before do
subject.follow!(bob)
end
it 'destroys a follow' do
unfollow = subject.unfollow!(bob)
expect(unfollow).to be_instance_of Follow
expect(unfollow.account).to eq subject
expect(unfollow.target_account).to eq bob
expect(unfollow.destroyed?).to be true
end
end
describe '#following?' do
it 'returns true when the target is followed' do
subject.follow!(bob)
expect(subject.following?(bob)).to be true
end
it 'returns false if the target is not followed' do
expect(subject.following?(bob)).to be false
end
end
end
describe '#local?' do
it 'returns true when the account is local' do
account = Fabricate(:account, domain: nil)
expect(account.local?).to be true
end
it 'returns false when the account is on a different domain' do
account = Fabricate(:account, domain: 'foreign.tld')
expect(account.local?).to be false
end
end
describe 'Local domain user methods' do
around do |example|
before = Rails.configuration.x.local_domain
example.run
Rails.configuration.x.local_domain = before
end
subject { Fabricate(:account, domain: nil, username: 'alice') }
describe '#to_webfinger_s' do
it 'returns a webfinger string for the account' do
Rails.configuration.x.local_domain = 'example.com'
expect(subject.to_webfinger_s).to eq 'acct:alice@example.com'
end
end
describe '#local_username_and_domain' do
it 'returns the username and local domain for the account' do
Rails.configuration.x.local_domain = 'example.com'
expect(subject.local_username_and_domain).to eq 'alice@example.com'
end
end
end
describe '#acct' do
it 'returns username for local users' do
account = Fabricate(:account, domain: nil, username: 'alice')
expect(account.acct).to eql 'alice'
end
it 'returns username@domain for foreign users' do
account = Fabricate(:account, domain: 'foreign.tld', username: 'alice')
expect(account.acct).to eql 'alice@foreign.tld'
end
end
describe '#save_with_optional_media!' do
before do
stub_request(:get, 'https://remote.test/valid_avatar').to_return(request_fixture('avatar.txt'))
stub_request(:get, 'https://remote.test/invalid_avatar').to_return(request_fixture('feed.txt'))
end
let(:account) do
Fabricate(:account,
avatar_remote_url: 'https://remote.test/valid_avatar',
header_remote_url: 'https://remote.test/valid_avatar')
end
let!(:expectation) { account.dup }
context 'with valid properties' do
before do
account.save_with_optional_media!
end
it 'unchanges avatar, header, avatar_remote_url, and header_remote_url' do
expect(account.avatar_remote_url).to eq expectation.avatar_remote_url
expect(account.header_remote_url).to eq expectation.header_remote_url
expect(account.avatar_file_name).to eq expectation.avatar_file_name
expect(account.header_file_name).to eq expectation.header_file_name
end
end
context 'with invalid properties' do
before do
account.avatar_remote_url = 'https://remote.test/invalid_avatar'
account.save_with_optional_media!
end
it 'sets default avatar, header, avatar_remote_url, and header_remote_url' do
expect(account.avatar_remote_url).to eq ''
expect(account.header_remote_url).to eq ''
expect(account.avatar_file_name).to eq nil
expect(account.header_file_name).to eq nil
end
end
end
describe '#subscribed?' do
it 'returns false when no subscription expiration information is present' do
account = Fabricate(:account, subscription_expires_at: nil)
expect(account.subscribed?).to be false
end
it 'returns true when subscription expiration has been set' do
account = Fabricate(:account, subscription_expires_at: 30.days.from_now)
expect(account.subscribed?).to be true
end
end
describe '#possibly_stale?' do
let(:account) { Fabricate(:account, last_webfingered_at: last_webfingered_at) }
context 'last_webfingered_at is nil' do
let(:last_webfingered_at) { nil }
it 'returns true' do
expect(account.possibly_stale?).to be true
end
end
context 'last_webfingered_at is more than 24 hours before' do
let(:last_webfingered_at) { 25.hours.ago }
it 'returns true' do
expect(account.possibly_stale?).to be true
end
end
context 'last_webfingered_at is less than 24 hours before' do
let(:last_webfingered_at) { 23.hours.ago }
it 'returns false' do
expect(account.possibly_stale?).to be false
end
end
end
describe '#refresh!' do
let(:account) { Fabricate(:account, domain: domain) }
let(:acct) { account.acct }
context 'domain is nil' do
let(:domain) { nil }
it 'returns nil' do
expect(account.refresh!).to be_nil
end
it 'calls not ResolveAccountService#call' do
expect_any_instance_of(ResolveAccountService).not_to receive(:call).with(acct)
account.refresh!
end
end
context 'domain is present' do
let(:domain) { 'example.com' }
it 'calls ResolveAccountService#call' do
expect_any_instance_of(ResolveAccountService).to receive(:call).with(acct).once
account.refresh!
end
end
end
describe '#to_param' do
it 'returns username' do
account = Fabricate(:account, username: 'alice')
expect(account.to_param).to eq 'alice'
end
end
describe '#keypair' do
it 'returns an RSA key pair' do
account = Fabricate(:account)
expect(account.keypair).to be_instance_of OpenSSL::PKey::RSA
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)
expect(account.object_type).to be :person
end
end
describe '#favourited?' do
let(:original_status) do
author = Fabricate(:account, username: 'original')
Fabricate(:status, account: author)
end
subject { Fabricate(:account) }
context 'when the status is a reblog of another status' do
let(:original_reblog) do
author = Fabricate(:account, username: 'original_reblogger')
Fabricate(:status, reblog: original_status, account: author)
end
it 'is is true when this account has favourited it' do
Fabricate(:favourite, status: original_reblog, account: subject)
expect(subject.favourited?(original_status)).to eq true
end
it 'is false when this account has not favourited it' do
expect(subject.favourited?(original_status)).to eq false
end
end
context 'when the status is an original status' do
it 'is is true when this account has favourited it' do
Fabricate(:favourite, status: original_status, account: subject)
expect(subject.favourited?(original_status)).to eq true
end
it 'is false when this account has not favourited it' do
expect(subject.favourited?(original_status)).to eq false
end
end
end
describe '#reblogged?' do
let(:original_status) do
author = Fabricate(:account, username: 'original')
Fabricate(:status, account: author)
end
subject { Fabricate(:account) }
context 'when the status is a reblog of another status'do
let(:original_reblog) do
author = Fabricate(:account, username: 'original_reblogger')
Fabricate(:status, reblog: original_status, account: author)
end
it 'is true when this account has reblogged it' do
Fabricate(:status, reblog: original_reblog, account: subject)
expect(subject.reblogged?(original_reblog)).to eq true
end
it 'is false when this account has not reblogged it' do
expect(subject.reblogged?(original_reblog)).to eq false
end
end
context 'when the status is an original status' do
it 'is true when this account has reblogged it' do
Fabricate(:status, reblog: original_status, account: subject)
expect(subject.reblogged?(original_status)).to eq true
end
it 'is false when this account has not reblogged it' do
expect(subject.reblogged?(original_status)).to eq false
end
end
end
describe '#excluded_from_timeline_account_ids' do
it 'includes account ids of blockings, blocked_bys and mutes' do
account = Fabricate(:account)
block = Fabricate(:block, account: account)
mute = Fabricate(:mute, account: account)
block_by = Fabricate(:block, target_account: account)
results = account.excluded_from_timeline_account_ids
expect(results.size).to eq 3
expect(results).to include(block.target_account.id)
expect(results).to include(mute.target_account.id)
expect(results).to include(block_by.account.id)
end
end
describe '#excluded_from_timeline_domains' do
it 'returns the domains blocked by the account' do
account = Fabricate(:account)
account.block_domain!('domain')
expect(account.excluded_from_timeline_domains).to match_array ['domain']
end
end
describe '.search_for' do
before do
_missing = Fabricate(
:account,
display_name: "Missing",
username: "missing",
domain: "missing.com"
)
end
it 'accepts ?, \, : and space as delimiter' do
match = Fabricate(
:account,
display_name: 'A & l & i & c & e',
username: 'username',
domain: 'example.com'
)
results = Account.search_for('A?l\i:c e')
expect(results).to eq [match]
end
it 'finds accounts with matching display_name' do
match = Fabricate(
:account,
display_name: "Display Name",
username: "username",
domain: "example.com"
)
results = Account.search_for("display")
expect(results).to eq [match]
end
it 'finds accounts with matching username' do
match = Fabricate(
:account,
display_name: "Display Name",
username: "username",
domain: "example.com"
)
results = Account.search_for("username")
expect(results).to eq [match]
end
it 'finds accounts with matching domain' do
match = Fabricate(
:account,
display_name: "Display Name",
username: "username",
domain: "example.com"
)
results = Account.search_for("example")
expect(results).to eq [match]
end
it 'limits by 10 by default' do
11.times.each { Fabricate(:account, display_name: "Display Name") }
results = Account.search_for("display")
expect(results.size).to eq 10
end
it 'accepts arbitrary limits' do
2.times.each { Fabricate(:account, display_name: "Display Name") }
results = Account.search_for("display", 1)
expect(results.size).to eq 1
end
it 'ranks multiple matches higher' do
matches = [
{ username: "username", display_name: "username" },
{ display_name: "Display Name", username: "username", domain: "example.com" },
].map(&method(:Fabricate).curry(2).call(:account))
results = Account.search_for("username")
expect(results).to eq matches
end
end
describe '.advanced_search_for' do
it 'accepts ?, \, : and space as delimiter' do
account = Fabricate(:account)
match = Fabricate(
:account,
display_name: 'A & l & i & c & e',
username: 'username',
domain: 'example.com'
)
results = Account.advanced_search_for('A?l\i:c e', account)
expect(results).to eq [match]
end
it 'limits by 10 by default' do
11.times { Fabricate(:account, display_name: "Display Name") }
results = Account.search_for("display")
expect(results.size).to eq 10
end
it 'accepts arbitrary limits' do
2.times { Fabricate(:account, display_name: "Display Name") }
results = Account.search_for("display", 1)
expect(results.size).to eq 1
end
it 'ranks followed accounts higher' do
account = Fabricate(:account)
match = Fabricate(:account, username: "Matching")
followed_match = Fabricate(:account, username: "Matcher")
Fabricate(:follow, account: account, target_account: followed_match)
results = Account.advanced_search_for("match", account)
expect(results).to eq [followed_match, match]
expect(results.first.rank).to be > results.last.rank
end
end
describe '.domains' do
it 'returns domains' do
Fabricate(:account, domain: 'domain')
expect(Account.domains).to match_array(['domain'])
end
end
describe '#statuses_count' do
subject { Fabricate(:account) }
it 'counts statuses' do
Fabricate(:status, account: subject)
Fabricate(:status, account: subject)
expect(subject.statuses_count).to eq 2
end
it 'does not count direct statuses' do
Fabricate(:status, account: subject, visibility: :direct)
expect(subject.statuses_count).to eq 0
end
it 'is decremented when status is removed' do
status = Fabricate(:status, account: subject)
expect(subject.statuses_count).to eq 1
status.destroy
expect(subject.statuses_count).to eq 0
end
it 'is decremented when status is removed when account is not preloaded' do
status = Fabricate(:status, account: subject)
expect(subject.reload.statuses_count).to eq 1
clean_status = Status.find(status.id)
expect(clean_status.association(:account).loaded?).to be false
clean_status.destroy
expect(subject.reload.statuses_count).to eq 0
end
end
describe '.following_map' do
it 'returns an hash' do
expect(Account.following_map([], 1)).to be_a Hash
end
end
describe '.followed_by_map' do
it 'returns an hash' do
expect(Account.followed_by_map([], 1)).to be_a Hash
end
end
describe '.blocking_map' do
it 'returns an hash' do
expect(Account.blocking_map([], 1)).to be_a Hash
end
end
describe '.requested_map' do
it 'returns an hash' do
expect(Account.requested_map([], 1)).to be_a Hash
end
end
describe 'MENTION_RE' do
subject { Account::MENTION_RE }
it 'matches usernames in the middle of a sentence' do
expect(subject.match('Hello to @alice from me')[1]).to eq 'alice'
end
it 'matches usernames in the beginning of status' do
expect(subject.match('@alice Hey how are you?')[1]).to eq 'alice'
end
it 'matches full usernames' do
expect(subject.match('@alice@example.com')[1]).to eq 'alice@example.com'
end
it 'matches full usernames with a dot at the end' do
expect(subject.match('Hello @alice@example.com.')[1]).to eq 'alice@example.com'
end
it 'matches dot-prepended usernames' do
expect(subject.match('.@alice I want everybody to see this')[1]).to eq 'alice'
end
it 'does not match e-mails' do
expect(subject.match('Drop me an e-mail at alice@example.com')).to be_nil
end
it 'does not match URLs' do
expect(subject.match('Check this out https://medium.com/@alice/some-article#.abcdef123')).to be_nil
end
xit 'does not match URL querystring' do
expect(subject.match('https://example.com/?x=@alice')).to be_nil
end
end
describe 'validations' do
it 'has a valid fabricator' do
account = Fabricate.build(:account)
account.valid?
expect(account).to be_valid
end
it 'is invalid without a username' do
account = Fabricate.build(:account, username: nil)
account.valid?
expect(account).to model_have_error_on_field(:username)
end
context 'when is local' do
it 'is invalid if the username is not unique in case-insensitive comparsion among local accounts' do
account_1 = Fabricate(:account, username: 'the_doctor')
account_2 = Fabricate.build(:account, username: 'the_Doctor')
account_2.valid?
expect(account_2).to model_have_error_on_field(:username)
end
it 'is invalid if the username is reserved' do
account = Fabricate.build(:account, username: 'support')
account.valid?
expect(account).to model_have_error_on_field(:username)
end
it 'is valid when username is reserved but record has already been created' do
account = Fabricate.build(:account, username: 'support')
account.save(validate: false)
expect(account.valid?).to be true
end
it 'is invalid if the username doesn\'t only contains letters, numbers and underscores' do
account = Fabricate.build(:account, username: 'the-doctor')
account.valid?
expect(account).to model_have_error_on_field(:username)
end
it 'is invalid if the username is longer then 30 characters' do
account = Fabricate.build(:account, username: Faker::Lorem.characters(31))
account.valid?
expect(account).to model_have_error_on_field(:username)
end
it 'is invalid if the display name is longer than 30 characters' do
account = Fabricate.build(:account, display_name: Faker::Lorem.characters(31))
account.valid?
expect(account).to model_have_error_on_field(:display_name)
end
it 'is invalid if the note is longer than 160 characters' do
account = Fabricate.build(:account, note: Faker::Lorem.characters(161))
account.valid?
expect(account).to model_have_error_on_field(:note)
end
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
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
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)
end
it 'is valid even if the username doesn\'t only contains letters, numbers and underscores' do
account = Fabricate.build(:account, domain: 'domain', username: 'the-doctor')
account.valid?
expect(account).not_to model_have_error_on_field(:username)
end
it 'is valid even if the username is longer then 30 characters' do
account = Fabricate.build(:account, domain: 'domain', username: Faker::Lorem.characters(31))
account.valid?
expect(account).not_to model_have_error_on_field(:username)
end
it 'is valid even if the display name is longer than 30 characters' do
account = Fabricate.build(:account, domain: 'domain', display_name: Faker::Lorem.characters(31))
account.valid?
expect(account).not_to model_have_error_on_field(:display_name)
end
it 'is valid even if the note is longer than 160 characters' do
account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(161))
account.valid?
expect(account).not_to model_have_error_on_field(:note)
end
end
end
describe 'scopes' do
describe 'alphabetic' do
it 'sorts by alphabetic order of domain and username' do
matches = [
{ username: 'a', domain: 'a' },
{ username: 'b', domain: 'a' },
{ username: 'a', domain: 'b' },
{ username: 'b', domain: 'b' },
].map(&method(:Fabricate).curry(2).call(:account))
expect(Account.alphabetic).to eq matches
end
end
describe 'matches_display_name' do
it 'matches display name which starts with the given string' do
match = Fabricate(:account, display_name: 'pattern and suffix')
Fabricate(:account, display_name: 'prefix and pattern')
expect(Account.matches_display_name('pattern')).to eq [match]
end
end
describe 'matches_username' do
it 'matches display name which starts with the given string' do
match = Fabricate(:account, username: 'pattern_and_suffix')
Fabricate(:account, username: 'prefix_and_pattern')
expect(Account.matches_username('pattern')).to eq [match]
end
end
describe 'expiring' do
it 'returns remote accounts with followers whose subscription expiration date is past or not given' do
local = Fabricate(:account, domain: nil)
matches = [
{ domain: 'remote', subscription_expires_at: '2000-01-01T00:00:00Z' },
].map(&method(:Fabricate).curry(2).call(:account))
matches.each(&local.method(:follow!))
Fabricate(:account, domain: 'remote', subscription_expires_at: nil)
local.follow!(Fabricate(:account, domain: 'remote', subscription_expires_at: '2000-01-03T00:00:00Z'))
local.follow!(Fabricate(:account, domain: nil, subscription_expires_at: nil))
expect(Account.expiring('2000-01-02T00:00:00Z').recent).to eq matches.reverse
end
end
describe 'remote' do
it 'returns an array of accounts who have a domain' do
account_1 = Fabricate(:account, domain: nil)
account_2 = Fabricate(:account, domain: 'example.com')
expect(Account.remote).to match_array([account_2])
end
end
describe 'by_domain_accounts' do
it 'returns accounts grouped by domain sorted by accounts' do
2.times { Fabricate(:account, domain: 'example.com') }
Fabricate(:account, domain: 'example2.com')
results = Account.by_domain_accounts
expect(results.length).to eq 2
expect(results.first.domain).to eq 'example.com'
expect(results.first.accounts_count).to eq 2
expect(results.last.domain).to eq 'example2.com'
expect(results.last.accounts_count).to eq 1
end
end
describe 'local' do
it 'returns an array of accounts who do not have a domain' do
account_1 = Fabricate(:account, domain: nil)
account_2 = Fabricate(:account, domain: 'example.com')
expect(Account.local).to match_array([account_1])
end
end
describe 'partitioned' do
it 'returns a relation of accounts partitioned by domain' do
matches = ['a', 'b', 'a', 'b']
matches.size.times.to_a.shuffle.each do |index|
matches[index] = Fabricate(:account, domain: matches[index])
end
expect(Account.partitioned).to match_array(matches)
end
end
describe 'recent' do
it 'returns a relation of accounts sorted by recent creation' do
matches = 2.times.map { Fabricate(:account) }
expect(Account.recent).to match_array(matches)
end
end
describe 'silenced' do
it 'returns an array of accounts who are silenced' do
account_1 = Fabricate(:account, silenced: true)
account_2 = Fabricate(:account, silenced: false)
expect(Account.silenced).to match_array([account_1])
end
end
describe 'suspended' do
it 'returns an array of accounts who are suspended' do
account_1 = Fabricate(:account, suspended: true)
account_2 = Fabricate(:account, suspended: false)
expect(Account.suspended).to match_array([account_1])
end
end
describe 'without_followers' do
it 'returns a relation of accounts without followers' do
account_1 = Fabricate(:account)
account_2 = Fabricate(:account)
Fabricate(:follow, account: account_1, target_account: account_2)
expect(Account.without_followers).to match_array([account_1])
end
end
describe 'with_followers' do
it 'returns a relation of accounts with followers' do
account_1 = Fabricate(:account)
account_2 = Fabricate(:account)
Fabricate(:follow, account: account_1, target_account: account_2)
expect(Account.with_followers).to match_array([account_2])
end
end
end
context 'when is local' do
# Test disabled because test environment omits autogenerating keys for performance
xit 'generates keys' do
account = Account.create!(domain: nil, username: Faker::Internet.user_name(nil, ['_']))
expect(account.keypair.private?).to eq true
end
end
context 'when is remote' do
it 'does not generate keys' do
key = OpenSSL::PKey::RSA.new(1024).public_key
account = Account.create!(domain: 'remote', username: Faker::Internet.user_name(nil, ['_']), public_key: key.to_pem)
expect(account.keypair.params).to eq key.params
end
it 'normalizes domain' do
account = Account.create!(domain: 'にゃん', username: Faker::Internet.user_name(nil, ['_']))
expect(account.domain).to eq 'xn--r9j5b5b'
end
end
include_examples 'AccountAvatar', :account
end