gearheads
/
mastodon
Archived
2
0
Fork 0

Refactor how public and tag timelines are queried (#14728)

gh/stable
Eugen Rochko 2020-09-07 11:02:04 +02:00 committed by GitHub
parent a6121a159c
commit e8bc187845
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 429 additions and 378 deletions

View File

@ -20,26 +20,25 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end end
def cached_public_statuses_page def cached_public_statuses_page
cache_collection_paginated_by_id( cache_collection(public_statuses, Status)
public_statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end end
def public_statuses def public_statuses
statuses = public_timeline_statuses public_feed.get(
limit_param(DEFAULT_STATUSES_LIMIT),
if truthy_param?(:only_media) params[:max_id],
statuses.joins(:media_attachments).group(:id) params[:since_id],
else params[:min_id]
statuses )
end
end end
def public_timeline_statuses def public_feed
Status.as_public_timeline(current_account, truthy_param?(:remote) ? :remote : truthy_param?(:local)) PublicFeed.new(
current_account,
local: truthy_param?(:local),
remote: truthy_param?(:remote),
only_media: truthy_param?(:only_media)
)
end end
def insert_pagination_headers def insert_pagination_headers

View File

@ -20,23 +20,29 @@ class Api::V1::Timelines::TagController < Api::BaseController
end end
def cached_tagged_statuses def cached_tagged_statuses
if @tag.nil? @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status)
[]
else
statuses = tag_timeline_statuses
statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media)
cache_collection_paginated_by_id(
statuses,
Status,
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
end end
def tag_timeline_statuses def tag_timeline_statuses
HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, truthy_param?(:local)) tag_feed.get(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id],
params[:min_id]
)
end
def tag_feed
TagFeed.new(
@tag,
current_account,
any: params[:any],
all: params[:all],
none: params[:none],
local: truthy_param?(:local),
remote: truthy_param?(:remote),
only_media: truthy_param?(:only_media)
)
end end
def insert_pagination_headers def insert_pagination_headers

View File

@ -10,8 +10,9 @@ class TagsController < ApplicationController
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :authenticate_user!, if: :whitelist_mode? before_action :authenticate_user!, if: :whitelist_mode?
before_action :set_tag
before_action :set_local before_action :set_local
before_action :set_tag
before_action :set_statuses
before_action :set_body_classes before_action :set_body_classes
before_action :set_instance_presenter before_action :set_instance_presenter
@ -25,20 +26,11 @@ class TagsController < ApplicationController
format.rss do format.rss do
expires_in 0, public: true expires_in 0, public: true
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(limit)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::TagSerializer.render(@tag, @statuses) render xml: RSS::TagSerializer.render(@tag, @statuses)
end end
format.json do format.json do
expires_in 3.minutes, public: public_fetch_mode? expires_in 3.minutes, public: public_fetch_mode?
@statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local).paginate_by_max_id(PAGE_SIZE, params[:max_id])
@statuses = cache_collection(@statuses, Status)
render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end end
end end
@ -54,6 +46,15 @@ class TagsController < ApplicationController
@local = truthy_param?(:local) @local = truthy_param?(:local)
end end
def set_statuses
case request.format&.to_sym
when :json
@statuses = cache_collection(TagFeed.new(@tag, current_account, local: @local).get(PAGE_SIZE, params[:max_id], params[:since_id], params[:min_id]), Status)
when :rss
@statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status)
end
end
def set_body_classes def set_body_classes
@body_classes = 'with-modals' @body_classes = 'with-modals'
end end
@ -62,16 +63,16 @@ class TagsController < ApplicationController
@instance_presenter = InstancePresenter.new @instance_presenter = InstancePresenter.new
end end
def limit_param
params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
end
def collection_presenter def collection_presenter
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
id: tag_url(@tag, filter_params), id: tag_url(@tag),
type: :ordered, type: :ordered,
size: @tag.statuses.count, size: @tag.statuses.count,
items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
) )
end end
def filter_params
params.slice(:any, :all, :none).permit(:any, :all, :none)
end
end end

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
class PublicFeed < Feed
# @param [Account] account
# @param [Hash] options
# @option [Boolean] :with_replies
# @option [Boolean] :with_reblogs
# @option [Boolean] :local
# @option [Boolean] :remote
# @option [Boolean] :only_media
def initialize(account, options = {})
@account = account
@options = options
end
# @param [Integer] limit
# @param [Integer] max_id
# @param [Integer] since_id
# @param [Integer] min_id
# @return [Array<Status>]
def get(limit, max_id = nil, since_id = nil, min_id = nil)
scope = public_scope
scope.merge!(without_replies_scope) unless with_replies?
scope.merge!(without_reblogs_scope) unless with_reblogs?
scope.merge!(local_only_scope) if local_only?
scope.merge!(remote_only_scope) if remote_only?
scope.merge!(account_filters_scope) if account?
scope.merge!(media_only_scope) if media_only?
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
end
private
def with_reblogs?
@options[:with_reblogs]
end
def with_replies?
@options[:with_replies]
end
def local_only?
@options[:local]
end
def remote_only?
@options[:remote]
end
def account?
@account.present?
end
def media_only?
@options[:only_media]
end
def public_scope
Status.with_public_visibility.joins(:account).merge(Account.without_suspended.without_silenced)
end
def local_only_scope
Status.local
end
def remote_only_scope
Status.remote
end
def without_replies_scope
Status.without_replies
end
def without_reblogs_scope
Status.without_reblogs
end
def media_only_scope
Status.joins(:media_attachments).group(:id)
end
def account_filters_scope
Status.not_excluded_by_account(@account).tap do |scope|
scope.merge!(Status.not_domain_blocked_by_account(@account)) unless local_only?
scope.merge!(Status.in_chosen_languages(@account)) if @account.chosen_languages.present?
end
end
end

View File

@ -85,12 +85,12 @@ class Status < ApplicationRecord
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :remote, -> { where(local: false).where.not(uri: nil) } scope :remote, -> { where(local: false).where.not(uri: nil) }
scope :local, -> { where(local: true).or(where(uri: nil)) } scope :local, -> { where(local: true).or(where(uri: nil)) }
scope :with_accounts, ->(ids) { where(id: ids).includes(:account) } scope :with_accounts, ->(ids) { where(id: ids).includes(:account) }
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') } scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
scope :with_public_visibility, -> { where(visibility: :public) } scope :with_public_visibility, -> { where(visibility: :public) }
scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) } scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) }
scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@ -277,26 +277,6 @@ class Status < ApplicationRecord
visibilities.keys - %w(direct limited) visibilities.keys - %w(direct limited)
end end
def in_chosen_languages(account)
where(language: nil).or where(language: account.chosen_languages)
end
def as_public_timeline(account = nil, local_only = false)
query = timeline_scope(local_only).without_replies
apply_timeline_filters(query, account, [:local, true].include?(local_only))
end
def as_tag_timeline(tag, account = nil, local_only = false)
query = timeline_scope(local_only).tagged_with(tag)
apply_timeline_filters(query, account, local_only)
end
def as_outbox_timeline(account)
where(account: account, visibility: :public)
end
def favourites_map(status_ids, account_id) def favourites_map(status_ids, account_id)
Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true } Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true }
end end
@ -373,51 +353,6 @@ class Status < ApplicationRecord
status&.distributable? ? status : nil status&.distributable? ? status : nil
end.compact end.compact
end end
private
def timeline_scope(scope = false)
starting_scope = case scope
when :local, true
Status.local
when :remote
Status.remote
else
Status
end
starting_scope
.with_public_visibility
.without_reblogs
end
def apply_timeline_filters(query, account, local_only)
if account.nil?
filter_timeline_default(query)
else
filter_timeline_for_account(query, account, local_only)
end
end
def filter_timeline_for_account(query, account, local_only)
query = query.not_excluded_by_account(account)
query = query.not_domain_blocked_by_account(account) unless local_only
query = query.in_chosen_languages(account) if account.chosen_languages.present?
query.merge(account_silencing_filter(account))
end
def filter_timeline_default(query)
query.excluding_silenced_accounts
end
def account_silencing_filter(account)
if account.silenced?
including_myself = left_outer_joins(:account).where(account_id: account.id).references(:accounts)
excluding_silenced_accounts.or(including_myself)
else
excluding_silenced_accounts
end
end
end end
def status_stat def status_stat

View File

@ -0,0 +1,57 @@
# frozen_string_literal: true
class TagFeed < PublicFeed
LIMIT_PER_MODE = 4
# @param [Tag] tag
# @param [Account] account
# @param [Hash] options
# @option [Enumerable<String>] :any
# @option [Enumerable<String>] :all
# @option [Enumerable<String>] :none
# @option [Boolean] :local
# @option [Boolean] :remote
# @option [Boolean] :only_media
def initialize(tag, account, options = {})
@tag = tag
@account = account
@options = options
end
# @param [Integer] limit
# @param [Integer] max_id
# @param [Integer] since_id
# @param [Integer] min_id
# @return [Array<Status>]
def get(limit, max_id = nil, since_id = nil, min_id = nil)
scope = public_scope
scope.merge!(tagged_with_any_scope)
scope.merge!(tagged_with_all_scope)
scope.merge!(tagged_with_none_scope)
scope.merge!(local_only_scope) if local_only?
scope.merge!(remote_only_scope) if remote_only?
scope.merge!(account_filters_scope) if account?
scope.merge!(media_only_scope) if media_only?
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
end
private
def tagged_with_any_scope
Status.group(:id).tagged_with(tags_for(Array(@tag.name) | Array(@options[:any])))
end
def tagged_with_all_scope
Status.group(:id).tagged_with_all(tags_for(@options[:all]))
end
def tagged_with_none_scope
Status.group(:id).tagged_with_none(tags_for(@options[:none]))
end
def tags_for(names)
Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
class HashtagQueryService < BaseService
LIMIT_PER_MODE = 4
def call(tag, params, account = nil, local = false)
tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id)
all = tags_for(params[:all])
none = tags_for(params[:none])
Status.group(:id)
.as_tag_timeline(tags, account, local)
.tagged_with_all(all)
.tagged_with_none(none)
end
private
def tags_for(names)
Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present?
end
end

View File

@ -0,0 +1,212 @@
require 'rails_helper'
RSpec.describe PublicFeed, type: :model do
let(:account) { Fabricate(:account) }
describe '#get' do
subject { described_class.new(nil).get(20).map(&:id) }
it 'only includes statuses with public visibility' do
public_status = Fabricate(:status, visibility: :public)
private_status = Fabricate(:status, visibility: :private)
expect(subject).to include(public_status.id)
expect(subject).not_to include(private_status.id)
end
it 'does not include replies' do
status = Fabricate(:status)
reply = Fabricate(:status, in_reply_to_id: status.id)
expect(subject).to include(status.id)
expect(subject).not_to include(reply.id)
end
it 'does not include boosts' do
status = Fabricate(:status)
boost = Fabricate(:status, reblog_of_id: status.id)
expect(subject).to include(status.id)
expect(subject).not_to include(boost.id)
end
it 'filters out silenced accounts' do
account = Fabricate(:account)
silenced_account = Fabricate(:account, silenced: true)
status = Fabricate(:status, account: account)
silenced_status = Fabricate(:status, account: silenced_account)
expect(subject).to include(status.id)
expect(subject).not_to include(silenced_status.id)
end
context 'without local_only option' do
let(:viewer) { nil }
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { described_class.new(viewer).get(20).map(&:id) }
context 'without a viewer' do
let(:viewer) { nil }
it 'includes remote instances statuses' do
expect(subject).to include(remote_status.id)
end
it 'includes local statuses' do
expect(subject).to include(local_status.id)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'includes remote instances statuses' do
expect(subject).to include(remote_status.id)
end
it 'includes local statuses' do
expect(subject).to include(local_status.id)
end
end
end
context 'with a local_only option set' do
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { described_class.new(viewer, local: true).get(20).map(&:id) }
context 'without a viewer' do
let(:viewer) { nil }
it 'does not include remote instances statuses' do
expect(subject).to include(local_status.id)
expect(subject).not_to include(remote_status.id)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'does not include remote instances statuses' do
expect(subject).to include(local_status.id)
expect(subject).not_to include(remote_status.id)
end
it 'is not affected by personal domain blocks' do
viewer.block_domain!('test.com')
expect(subject).to include(local_status.id)
expect(subject).not_to include(remote_status.id)
end
end
end
context 'with a remote_only option set' do
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { described_class.new(viewer, remote: true).get(20).map(&:id) }
context 'without a viewer' do
let(:viewer) { nil }
it 'does not include local instances statuses' do
expect(subject).not_to include(local_status.id)
expect(subject).to include(remote_status.id)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'does not include local instances statuses' do
expect(subject).not_to include(local_status.id)
expect(subject).to include(remote_status.id)
end
end
end
describe 'with an account passed in' do
before do
@account = Fabricate(:account)
end
subject { described_class.new(@account).get(20).map(&:id) }
it 'excludes statuses from accounts blocked by the account' do
blocked = Fabricate(:account)
@account.block!(blocked)
blocked_status = Fabricate(:status, account: blocked)
expect(subject).not_to include(blocked_status.id)
end
it 'excludes statuses from accounts who have blocked the account' do
blocker = Fabricate(:account)
blocker.block!(@account)
blocked_status = Fabricate(:status, account: blocker)
expect(subject).not_to include(blocked_status.id)
end
it 'excludes statuses from accounts muted by the account' do
muted = Fabricate(:account)
@account.mute!(muted)
muted_status = Fabricate(:status, account: muted)
expect(subject).not_to include(muted_status.id)
end
it 'excludes statuses from accounts from personally blocked domains' do
blocked = Fabricate(:account, domain: 'example.com')
@account.block_domain!(blocked.domain)
blocked_status = Fabricate(:status, account: blocked)
expect(subject).not_to include(blocked_status.id)
end
context 'with language preferences' do
it 'excludes statuses in languages not allowed by the account user' do
user = Fabricate(:user, chosen_languages: [:en, :es])
@account.update(user: user)
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
fr_status = Fabricate(:status, language: 'fr')
expect(subject).to include(en_status.id)
expect(subject).to include(es_status.id)
expect(subject).not_to include(fr_status.id)
end
it 'includes all languages when user does not have a setting' do
user = Fabricate(:user, chosen_languages: nil)
@account.update(user: user)
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
expect(subject).to include(en_status.id)
expect(subject).to include(es_status.id)
end
it 'includes all languages when account does not have a user' do
expect(@account.user).to be_nil
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
expect(subject).to include(en_status.id)
expect(subject).to include(es_status.id)
end
end
end
end
end

View File

@ -267,241 +267,6 @@ RSpec.describe Status, type: :model do
end end
end end
describe '.as_public_timeline' do
it 'only includes statuses with public visibility' do
public_status = Fabricate(:status, visibility: :public)
private_status = Fabricate(:status, visibility: :private)
results = Status.as_public_timeline
expect(results).to include(public_status)
expect(results).not_to include(private_status)
end
it 'does not include replies' do
status = Fabricate(:status)
reply = Fabricate(:status, in_reply_to_id: status.id)
results = Status.as_public_timeline
expect(results).to include(status)
expect(results).not_to include(reply)
end
it 'does not include boosts' do
status = Fabricate(:status)
boost = Fabricate(:status, reblog_of_id: status.id)
results = Status.as_public_timeline
expect(results).to include(status)
expect(results).not_to include(boost)
end
it 'filters out silenced accounts' do
account = Fabricate(:account)
silenced_account = Fabricate(:account, silenced: true)
status = Fabricate(:status, account: account)
silenced_status = Fabricate(:status, account: silenced_account)
results = Status.as_public_timeline
expect(results).to include(status)
expect(results).not_to include(silenced_status)
end
context 'without local_only option' do
let(:viewer) { nil }
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { Status.as_public_timeline(viewer, false) }
context 'without a viewer' do
let(:viewer) { nil }
it 'includes remote instances statuses' do
expect(subject).to include(remote_status)
end
it 'includes local statuses' do
expect(subject).to include(local_status)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'includes remote instances statuses' do
expect(subject).to include(remote_status)
end
it 'includes local statuses' do
expect(subject).to include(local_status)
end
end
end
context 'with a local_only option set' do
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { Status.as_public_timeline(viewer, true) }
context 'without a viewer' do
let(:viewer) { nil }
it 'does not include remote instances statuses' do
expect(subject).to include(local_status)
expect(subject).not_to include(remote_status)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'does not include remote instances statuses' do
expect(subject).to include(local_status)
expect(subject).not_to include(remote_status)
end
it 'is not affected by personal domain blocks' do
viewer.block_domain!('test.com')
expect(subject).to include(local_status)
expect(subject).not_to include(remote_status)
end
end
end
context 'with a remote_only option set' do
let!(:local_account) { Fabricate(:account, domain: nil) }
let!(:remote_account) { Fabricate(:account, domain: 'test.com') }
let!(:local_status) { Fabricate(:status, account: local_account) }
let!(:remote_status) { Fabricate(:status, account: remote_account) }
subject { Status.as_public_timeline(viewer, :remote) }
context 'without a viewer' do
let(:viewer) { nil }
it 'does not include local instances statuses' do
expect(subject).not_to include(local_status)
expect(subject).to include(remote_status)
end
end
context 'with a viewer' do
let(:viewer) { Fabricate(:account, username: 'viewer') }
it 'does not include local instances statuses' do
expect(subject).not_to include(local_status)
expect(subject).to include(remote_status)
end
end
end
describe 'with an account passed in' do
before do
@account = Fabricate(:account)
end
it 'excludes statuses from accounts blocked by the account' do
blocked = Fabricate(:account)
Fabricate(:block, account: @account, target_account: blocked)
blocked_status = Fabricate(:status, account: blocked)
results = Status.as_public_timeline(@account)
expect(results).not_to include(blocked_status)
end
it 'excludes statuses from accounts who have blocked the account' do
blocked = Fabricate(:account)
Fabricate(:block, account: blocked, target_account: @account)
blocked_status = Fabricate(:status, account: blocked)
results = Status.as_public_timeline(@account)
expect(results).not_to include(blocked_status)
end
it 'excludes statuses from accounts muted by the account' do
muted = Fabricate(:account)
Fabricate(:mute, account: @account, target_account: muted)
muted_status = Fabricate(:status, account: muted)
results = Status.as_public_timeline(@account)
expect(results).not_to include(muted_status)
end
it 'excludes statuses from accounts from personally blocked domains' do
blocked = Fabricate(:account, domain: 'example.com')
@account.block_domain!(blocked.domain)
blocked_status = Fabricate(:status, account: blocked)
results = Status.as_public_timeline(@account)
expect(results).not_to include(blocked_status)
end
context 'with language preferences' do
it 'excludes statuses in languages not allowed by the account user' do
user = Fabricate(:user, chosen_languages: [:en, :es])
@account.update(user: user)
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
fr_status = Fabricate(:status, language: 'fr')
results = Status.as_public_timeline(@account)
expect(results).to include(en_status)
expect(results).to include(es_status)
expect(results).not_to include(fr_status)
end
it 'includes all languages when user does not have a setting' do
user = Fabricate(:user, chosen_languages: nil)
@account.update(user: user)
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
results = Status.as_public_timeline(@account)
expect(results).to include(en_status)
expect(results).to include(es_status)
end
it 'includes all languages when account does not have a user' do
expect(@account.user).to be_nil
en_status = Fabricate(:status, language: 'en')
es_status = Fabricate(:status, language: 'es')
results = Status.as_public_timeline(@account)
expect(results).to include(en_status)
expect(results).to include(es_status)
end
end
end
end
describe '.as_tag_timeline' do
it 'includes statuses with a tag' do
tag = Fabricate(:tag)
status = Fabricate(:status, tags: [tag])
other = Fabricate(:status)
results = Status.as_tag_timeline(tag)
expect(results).to include(status)
expect(results).not_to include(other)
end
it 'allows replies to be included' do
original = Fabricate(:status)
tag = Fabricate(:tag)
status = Fabricate(:status, tags: [tag], in_reply_to_id: original.id)
results = Status.as_tag_timeline(tag)
expect(results).to include(status)
end
end
describe '.permitted_for' do describe '.permitted_for' do
subject { described_class.permitted_for(target_account, account).pluck(:visibility) } subject { described_class.permitted_for(target_account, account).pluck(:visibility) }

View File

@ -1,7 +1,7 @@
require 'rails_helper' require 'rails_helper'
describe HashtagQueryService, type: :service do describe TagFeed, type: :service do
describe '.call' do describe '#get' do
let(:account) { Fabricate(:account) } let(:account) { Fabricate(:account) }
let(:tag1) { Fabricate(:tag) } let(:tag1) { Fabricate(:tag) }
let(:tag2) { Fabricate(:tag) } let(:tag2) { Fabricate(:tag) }
@ -10,35 +10,35 @@ describe HashtagQueryService, type: :service do
let!(:both) { Fabricate(:status, tags: [tag1, tag2]) } let!(:both) { Fabricate(:status, tags: [tag1, tag2]) }
it 'can add tags in "any" mode' do it 'can add tags in "any" mode' do
results = subject.call(tag1, { any: [tag2.name] }) results = described_class.new(tag1, nil, any: [tag2.name]).get(20)
expect(results).to include status1 expect(results).to include status1
expect(results).to include status2 expect(results).to include status2
expect(results).to include both expect(results).to include both
end end
it 'can remove tags in "all" mode' do it 'can remove tags in "all" mode' do
results = subject.call(tag1, { all: [tag2.name] }) results = described_class.new(tag1, nil, all: [tag2.name]).get(20)
expect(results).to_not include status1 expect(results).to_not include status1
expect(results).to_not include status2 expect(results).to_not include status2
expect(results).to include both expect(results).to include both
end end
it 'can remove tags in "none" mode' do it 'can remove tags in "none" mode' do
results = subject.call(tag1, { none: [tag2.name] }) results = described_class.new(tag1, nil, none: [tag2.name]).get(20)
expect(results).to include status1 expect(results).to include status1
expect(results).to_not include status2 expect(results).to_not include status2
expect(results).to_not include both expect(results).to_not include both
end end
it 'ignores an invalid mode' do it 'ignores an invalid mode' do
results = subject.call(tag1, { wark: [tag2.name] }) results = described_class.new(tag1, nil, wark: [tag2.name]).get(20)
expect(results).to include status1 expect(results).to include status1
expect(results).to_not include status2 expect(results).to_not include status2
expect(results).to include both expect(results).to include both
end end
it 'handles being passed non existant tag names' do it 'handles being passed non existant tag names' do
results = subject.call(tag1, { any: ['wark'] }) results = described_class.new(tag1, nil, any: ['wark']).get(20)
expect(results).to include status1 expect(results).to include status1
expect(results).to_not include status2 expect(results).to_not include status2
expect(results).to include both expect(results).to include both
@ -46,15 +46,23 @@ describe HashtagQueryService, type: :service do
it 'can restrict to an account' do it 'can restrict to an account' do
BlockService.new.call(account, status1.account) BlockService.new.call(account, status1.account)
results = subject.call(tag1, { none: [tag2.name] }, account) results = described_class.new(tag1, account, none: [tag2.name]).get(20)
expect(results).to_not include status1 expect(results).to_not include status1
end end
it 'can restrict to local' do it 'can restrict to local' do
status1.account.update(domain: 'example.com') status1.account.update(domain: 'example.com')
status1.update(local: false, uri: 'example.com/toot') status1.update(local: false, uri: 'example.com/toot')
results = subject.call(tag1, { any: [tag2.name] }, nil, true) results = described_class.new(tag1, nil, any: [tag2.name], local: true).get(20)
expect(results).to_not include status1 expect(results).to_not include status1
end end
it 'allows replies to be included' do
original = Fabricate(:status)
status = Fabricate(:status, tags: [tag1], in_reply_to_id: original.id)
results = described_class.new(tag1, nil).get(20)
expect(results).to include(status)
end
end end
end end

View File

@ -28,10 +28,10 @@ RSpec.describe FanOutOnWriteService, type: :service do
end end
it 'delivers status to hashtag' do it 'delivers status to hashtag' do
expect(Tag.find_by!(name: 'test').statuses.pluck(:id)).to include status.id expect(TagFeed.new(Tag.find_by(name: 'test'), alice).get(20).map(&:id)).to include status.id
end end
it 'delivers status to public timeline' do it 'delivers status to public timeline' do
expect(Status.as_public_timeline(alice).map(&:id)).to include status.id expect(PublicFeed.new(alice).get(20).map(&:id)).to include status.id
end end
end end