Merge commit '55e7c08a83547424024bac311d5459cb82cf6dae' into glitch-soc/merge-upstream

Conflicts:
- `app/models/user_settings.rb`:
  Upstream added a constraint on a setting textually close
  to glitch-soc-only settings.
  Applied upstream's change.
- `lib/sanitize_ext/sanitize_config.rb`:
  Upstream added support for the `translate` attribute on a few elements,
  where glitch-soc had a different set of allowed elements and attributes.
  Extended glitch-soc's allowed attributes with `translate` as upstream did.
- `spec/validators/status_length_validator_spec.rb`:
  Upstream refactored to use RSpec's `instance_double` instead of `double`,
  but glitch-soc had changes to tests due to configurable max toot chars.
  Applied upstream's changes while keeping tests against configurable max
  toot chars.
main
Claire 2023-06-25 12:02:52 +02:00
commit 178e151019
117 changed files with 1235 additions and 862 deletions

View File

@ -318,7 +318,6 @@ RSpec/LetSetup:
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb'
- 'spec/controllers/api/v1/followed_tags_controller_spec.rb' - 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
- 'spec/controllers/api/v1/tags_controller_spec.rb'
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v2/filters/keywords_controller_spec.rb' - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb' - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
@ -440,45 +439,6 @@ RSpec/SubjectStub:
- 'spec/services/unallow_domain_service_spec.rb' - 'spec/services/unallow_domain_service_spec.rb'
- 'spec/validators/blacklisted_email_validator_spec.rb' - 'spec/validators/blacklisted_email_validator_spec.rb'
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
RSpec/VerifiedDoubles:
Exclude:
- 'spec/controllers/admin/change_emails_controller_spec.rb'
- 'spec/controllers/admin/confirmations_controller_spec.rb'
- 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
- 'spec/controllers/admin/domain_allows_controller_spec.rb'
- 'spec/controllers/admin/domain_blocks_controller_spec.rb'
- 'spec/controllers/api/v1/reports_controller_spec.rb'
- 'spec/controllers/api/web/embeds_controller_spec.rb'
- 'spec/controllers/auth/sessions_controller_spec.rb'
- 'spec/controllers/disputes/appeals_controller_spec.rb'
- 'spec/helpers/statuses_helper_spec.rb'
- 'spec/lib/suspicious_sign_in_detector_spec.rb'
- 'spec/models/account/field_spec.rb'
- 'spec/models/session_activation_spec.rb'
- 'spec/models/setting_spec.rb'
- 'spec/services/account_search_service_spec.rb'
- 'spec/services/post_status_service_spec.rb'
- 'spec/services/search_service_spec.rb'
- 'spec/validators/blacklisted_email_validator_spec.rb'
- 'spec/validators/disallowed_hashtags_validator_spec.rb'
- 'spec/validators/email_mx_validator_spec.rb'
- 'spec/validators/follow_limit_validator_spec.rb'
- 'spec/validators/note_length_validator_spec.rb'
- 'spec/validators/poll_validator_spec.rb'
- 'spec/validators/status_length_validator_spec.rb'
- 'spec/validators/status_pin_validator_spec.rb'
- 'spec/validators/unique_username_validator_spec.rb'
- 'spec/validators/unreserved_username_validator_spec.rb'
- 'spec/validators/url_validator_spec.rb'
- 'spec/views/statuses/show.html.haml_spec.rb'
- 'spec/workers/activitypub/processing_worker_spec.rb'
- 'spec/workers/admin/domain_purge_worker_spec.rb'
- 'spec/workers/domain_block_worker_spec.rb'
- 'spec/workers/domain_clear_media_worker_spec.rb'
- 'spec/workers/feed_insert_worker_spec.rb'
- 'spec/workers/regeneration_worker_spec.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
@ -759,7 +719,6 @@ Rails/WhereExists:
- 'app/workers/move_worker.rb' - 'app/workers/move_worker.rb'
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb' - 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
- 'lib/tasks/tests.rake' - 'lib/tasks/tests.rake'
- 'spec/controllers/api/v1/tags_controller_spec.rb'
- 'spec/models/account_spec.rb' - 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb' - 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb' - 'spec/services/purge_domain_service_spec.rb'

View File

@ -106,7 +106,7 @@ GEM
aws-sdk-kms (1.67.0) aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.125.0) aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)

View File

@ -28,6 +28,7 @@ module Admin
authorize :webhook, :create? authorize :webhook, :create?
@webhook = Webhook.new(resource_params) @webhook = Webhook.new(resource_params)
@webhook.current_account = current_account
if @webhook.save if @webhook.save
redirect_to admin_webhook_path(@webhook) redirect_to admin_webhook_path(@webhook)
@ -39,10 +40,12 @@ module Admin
def update def update
authorize @webhook, :update? authorize @webhook, :update?
@webhook.current_account = current_account
if @webhook.update(resource_params) if @webhook.update(resource_params)
redirect_to admin_webhook_path(@webhook) redirect_to admin_webhook_path(@webhook)
else else
render :show render :edit
end end
end end

View File

@ -19,6 +19,11 @@ class Api::V1::ConversationsController < Api::BaseController
render json: @conversation, serializer: REST::ConversationSerializer render json: @conversation, serializer: REST::ConversationSerializer
end end
def unread
@conversation.update!(unread: true)
render json: @conversation, serializer: REST::ConversationSerializer
end
def destroy def destroy
@conversation.destroy! @conversation.destroy!
render_empty render_empty

View File

@ -8,11 +8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
def show def show
cache_if_unauthenticated! cache_if_unauthenticated!
render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer render json: status_edits, each_serializer: REST::StatusEditSerializer
end end
private private
def status_edits
@status.edits.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)]
end
def set_status def set_status
@status = Status.find(params[:status_id]) @status = Status.find(params[:status_id])
authorize @status, :show? authorize @status, :show?

View File

@ -18,6 +18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
private private
def next_path
api_v2_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v2_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty?
end
def filtered_accounts def filtered_accounts
AccountFilter.new(translated_filter_params).results AccountFilter.new(translated_filter_params).results
end end

View File

@ -24,13 +24,4 @@ module SettingsHelper
safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ') safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
end end
end end
def picture_hint(hint, picture)
if picture.original_filename.nil?
hint
else
link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete }
safe_join([hint, link], '<br/>'.html_safe)
end
end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@ -1,6 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -49,6 +49,7 @@ class Account extends ImmutablePureComponent {
actionTitle: PropTypes.string, actionTitle: PropTypes.string,
defaultAction: PropTypes.string, defaultAction: PropTypes.string,
onActionClick: PropTypes.func, onActionClick: PropTypes.func,
withBio: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -80,7 +81,7 @@ class Account extends ImmutablePureComponent {
}; };
render () { render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props; const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
if (!account) { if (!account) {
return <EmptyAccount size={size} minimal={minimal} />; return <EmptyAccount size={size} minimal={minimal} />;
@ -171,6 +172,15 @@ class Account extends ImmutablePureComponent {
</div> </div>
)} )}
</div> </div>
{withBio && (account.get('note').length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
) : (
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
))}
</div> </div>
); );
} }

View File

@ -1,44 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number';
export default class AutosuggestHashtag extends PureComponent {
static propTypes = {
tag: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string,
history: PropTypes.array,
}).isRequired,
};
render() {
const { tag } = this.props;
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div>
);
}
}

View File

@ -0,0 +1,42 @@
import { FormattedMessage } from 'react-intl';
import ShortNumber from 'mastodon/components/short_number';
interface Props {
tag: {
name: string;
url?: string;
history?: Array<{
uses: number;
accounts: string;
day: string;
}>;
following?: boolean;
type: 'hashtag';
};
}
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
const weeklyUses = tag.history && (
<ShortNumber
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
/>
);
return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>
#<strong>{tag.name}</strong>
</div>
{tag.history !== undefined && (
<div className='autosuggest-hashtag__uses'>
<FormattedMessage
id='autosuggest_hashtag.per_week'
defaultMessage='{count} per week'
values={{ count: weeklyUses }}
/>
</div>
)}
</div>
);
};

View File

@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag'; import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => { const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word; let word;

View File

@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag'; import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => { const textAtCursorMatchesToken = (str, caretPosition) => {
let word; let word;

View File

@ -1,11 +1,27 @@
import { Icon } from './icon'; import { Icon } from './icon';
const domParser = new DOMParser();
const stripRelMe = (html: string) => {
const document = domParser.parseFromString(html, 'text/html').documentElement;
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
link.rel = link.rel
.split(' ')
.filter((x: string) => x !== 'me')
.join(' ');
});
const body = document.querySelector('body');
return body ? { __html: body.innerHTML } : undefined;
};
interface Props { interface Props {
link: string; link: string;
} }
export const VerifiedBadge: React.FC<Props> = ({ link }) => ( export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'> <span className='verified-badge'>
<Icon id='check' className='verified-badge__mark' /> <Icon id='check' className='verified-badge__mark' />
<span dangerouslySetInnerHTML={{ __html: link }} /> <span dangerouslySetInnerHTML={stripRelMe(link)} />
</span> </span>
); );

View File

@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list'; import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']), statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true), isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']), hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
}); });

View File

@ -140,11 +140,8 @@ class CommunityTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} /> <ColumnSettingsContainer columnId={columnId} />
</ColumnHeader> </ColumnHeader>
<DismissableBanner id='community_timeline'>
<FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
</DismissableBanner>
<StatusListContainer <StatusListContainer
prepend={<DismissableBanner id='community_timeline'><FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} /></DismissableBanner>}
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`community_timeline-${columnId}`} scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`} timelineId={`community${onlyMedia ? ':media' : ''}`}

View File

@ -389,7 +389,7 @@ class EmojiPickerDropdown extends PureComponent {
{button || <img {button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })} className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂' alt='🙂'
src={`${assetHost}/emoji/1f602.svg`} src={`${assetHost}/emoji/1f642.svg`}
/>} />}
</div> </div>

View File

@ -35,7 +35,7 @@ class Links extends PureComponent {
const banner = ( const banner = (
<DismissableBanner id='explore/links'> <DismissableBanner id='explore/links'>
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' /> <FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' />
</DismissableBanner> </DismissableBanner>
); );

View File

@ -11,9 +11,10 @@ import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends'; import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner'; import DismissableBanner from 'mastodon/components/dismissable_banner';
import StatusList from 'mastodon/components/status_list'; import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'trending', 'items']), statusIds: getStatusList(state, 'trending'),
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true), isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'trending', 'next']), hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
}); });
@ -46,7 +47,7 @@ class Statuses extends PureComponent {
return ( return (
<> <>
<DismissableBanner id='explore/statuses'> <DismissableBanner id='explore/statuses'>
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' /> <FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.' />
</DismissableBanner> </DismissableBanner>
<StatusList <StatusList

View File

@ -34,7 +34,7 @@ class Tags extends PureComponent {
const banner = ( const banner = (
<DismissableBanner id='explore/tags'> <DismissableBanner id='explore/tags'>
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' /> <FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' />
</DismissableBanner> </DismissableBanner>
); );

View File

@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list'; import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' }, heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']), statusIds: getStatusList(state, 'favourites'),
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true), isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']), hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
}); });

View File

@ -0,0 +1,24 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import background from 'mastodon/../images/friends-cropped.png';
import DismissableBanner from 'mastodon/components/dismissable_banner';
export const ExplorePrompt = () => (
<DismissableBanner id='home.explore_prompt'>
<img src={background} alt='' className='dismissable-banner__background-image' />
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
<div className='dismissable-banner__message__actions'>
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
</div>
</DismissableBanner>
);

View File

@ -5,14 +5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { me } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
@ -20,6 +22,7 @@ import Column from '../../components/column';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container'; import StatusListContainer from '../ui/containers/status_list_container';
import { ExplorePrompt } from './components/explore_prompt';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({ const messages = defineMessages({
@ -28,12 +31,33 @@ const messages = defineMessages({
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' }, hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
}); });
const getHomeFeedSpeed = createSelector([
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
state => state.get('statuses'),
], (statusIds, statusMap) => {
const statuses = statusIds.map(id => statusMap.get(id)).filter(status => status.get('account') !== me).take(20);
const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
const newest = new Date(statuses.getIn([0, 'created_at'], 0));
const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
return {
gap: averageGap,
newest,
};
});
const homeTooSlow = createSelector(getHomeFeedSpeed, speed =>
speed.gap > (30 * 60) // If the average gap between posts is more than 20 minutes
|| (Date.now() - speed.newest) > (1000 * 3600) // If the most recent post is from over an hour ago
);
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0, hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']), isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(), hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')), unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']), showAnnouncements: state.getIn(['announcements', 'show']),
tooSlow: homeTooSlow(state),
}); });
class HomeTimeline extends PureComponent { class HomeTimeline extends PureComponent {
@ -52,6 +76,7 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool, hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number, unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool, showAnnouncements: PropTypes.bool,
tooSlow: PropTypes.bool,
}; };
handlePin = () => { handlePin = () => {
@ -121,11 +146,11 @@ class HomeTimeline extends PureComponent {
}; };
render () { render () {
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props; const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
let announcementsButton = null; let announcementsButton, banner;
if (hasAnnouncements) { if (hasAnnouncements) {
announcementsButton = ( announcementsButton = (
@ -141,6 +166,10 @@ class HomeTimeline extends PureComponent {
); );
} }
if (tooSlow) {
banner = <ExplorePrompt />;
}
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
@ -160,11 +189,13 @@ class HomeTimeline extends PureComponent {
{signedIn ? ( {signedIn ? (
<StatusListContainer <StatusListContainer
prepend={banner}
alwaysPrepend
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`} scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
timelineId='home' timelineId='home'
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />} emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up.' />}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
/> />
) : <NotSignedInIndicator />} ) : <NotSignedInIndicator />}

View File

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { Check } from 'mastodon/components/check'; import { Check } from 'mastodon/components/check';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import ArrowSmallRight from './arrow_small_right';
const Step = ({ label, description, icon, completed, onClick, href }) => { const Step = ({ label, description, icon, completed, onClick, href }) => {
const content = ( const content = (
<> <>
@ -15,11 +17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {
<p>{description}</p> <p>{description}</p>
</div> </div>
{completed && ( <div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
<div className='onboarding__steps__item__progress'> {completed ? <Check /> : <ArrowSmallRight />}
<Check />
</div> </div>
)}
</> </>
); );

View File

@ -12,20 +12,11 @@ import Column from 'mastodon/components/column';
import ColumnBackButton from 'mastodon/components/column_back_button'; import ColumnBackButton from 'mastodon/components/column_back_button';
import { EmptyAccount } from 'mastodon/components/empty_account'; import { EmptyAccount } from 'mastodon/components/empty_account';
import Account from 'mastodon/containers/account_container'; import Account from 'mastodon/containers/account_container';
import { me } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
import ProgressIndicator from './components/progress_indicator'; const mapStateToProps = state => ({
const mapStateToProps = () => {
const getAccount = makeGetAccount();
return state => ({
account: getAccount(state, me),
suggestions: state.getIn(['suggestions', 'items']), suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']), isLoading: state.getIn(['suggestions', 'isLoading']),
}); });
};
class Follows extends PureComponent { class Follows extends PureComponent {
@ -33,7 +24,6 @@ class Follows extends PureComponent {
onBack: PropTypes.func, onBack: PropTypes.func,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list, suggestions: ImmutablePropTypes.list,
account: ImmutablePropTypes.map,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@ -49,7 +39,7 @@ class Follows extends PureComponent {
} }
render () { render () {
const { onBack, isLoading, suggestions, account, multiColumn } = this.props; const { onBack, isLoading, suggestions, multiColumn } = this.props;
let loadedContent; let loadedContent;
@ -58,7 +48,7 @@ class Follows extends PureComponent {
} else if (suggestions.isEmpty()) { } else if (suggestions.isEmpty()) {
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>; loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
} else { } else {
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} />); loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
} }
return ( return (
@ -71,8 +61,6 @@ class Follows extends PureComponent {
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p> <p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
</div> </div>
<ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
<div className='follow-recommendations'> <div className='follow-recommendations'>
{loadedContent} {loadedContent}
</div> </div>

View File

@ -19,6 +19,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors'; import { makeGetAccount } from 'mastodon/selectors';
import { assetHost } from 'mastodon/utils/config';
import ArrowSmallRight from './components/arrow_small_right'; import ArrowSmallRight from './components/arrow_small_right';
import Step from './components/step'; import Step from './components/step';
@ -122,21 +123,22 @@ class Onboarding extends ImmutablePureComponent {
<div className='onboarding__steps'> <div className='onboarding__steps'>
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} /> <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} /> <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} /> <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} /> <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
</div> </div>
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage='Want to skip right ahead?' /></p> <p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
<div className='onboarding__links'> <div className='onboarding__links'>
<Link to='/explore' className='onboarding__link'> <Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight /> <ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link> </Link>
</div>
<div className='onboarding__footer'> <Link to='/home' className='onboarding__link'>
<button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button> <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
</Link>
</div> </div>
</div> </div>

View File

@ -177,13 +177,13 @@ class Share extends PureComponent {
<div className='onboarding__links'> <div className='onboarding__links'>
<Link to='/home' className='onboarding__link'> <Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight /> <ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
</Link> </Link>
<Link to='/explore' className='onboarding__link'> <Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight /> <ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link> </Link>
</div> </div>

View File

@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getStatusList } from 'mastodon/selectors';
import { fetchPinnedStatuses } from '../../actions/pin_statuses'; import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list'; import StatusList from '../../components/status_list';
@ -18,7 +20,7 @@ const messages = defineMessages({
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'pins', 'items']), statusIds: getStatusList(state, 'pins'),
hasMore: !!state.getIn(['status_lists', 'pins', 'next']), hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
}); });

View File

@ -142,11 +142,8 @@ class PublicTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} /> <ColumnSettingsContainer columnId={columnId} />
</ColumnHeader> </ColumnHeader>
<DismissableBanner id='public_timeline'>
<FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
</DismissableBanner>
<StatusListContainer <StatusListContainer
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`} timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
trackScroll={!pinned} trackScroll={!pinned}

View File

@ -8,6 +8,7 @@ import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { fetchServer } from 'mastodon/actions/server';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo'; import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
import { registrationsOpen, me } from 'mastodon/initial_state'; import { registrationsOpen, me } from 'mastodon/initial_state';
@ -28,6 +29,9 @@ const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() { openClosedRegistrationsModal() {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
}, },
dispatchServer() {
dispatch(fetchServer());
}
}); });
class Header extends PureComponent { class Header extends PureComponent {
@ -40,8 +44,14 @@ class Header extends PureComponent {
openClosedRegistrationsModal: PropTypes.func, openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object, location: PropTypes.object,
signupUrl: PropTypes.string.isRequired, signupUrl: PropTypes.string.isRequired,
dispatchServer: PropTypes.func
}; };
componentDidMount () {
const { dispatchServer } = this.props;
dispatchServer();
}
render () { render () {
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
const { location, openClosedRegistrationsModal, signupUrl } = this.props; const { location, openClosedRegistrationsModal, signupUrl } = this.props;

View File

@ -52,6 +52,7 @@
"account.mute_notifications_short": "Mute notifications", "account.mute_notifications_short": "Mute notifications",
"account.mute_short": "Mute", "account.mute_short": "Mute",
"account.muted": "Muted", "account.muted": "Muted",
"account.no_bio": "No description provided.",
"account.open_original_page": "Open original page", "account.open_original_page": "Open original page",
"account.posts": "Posts", "account.posts": "Posts",
"account.posts_with_replies": "Posts and replies", "account.posts_with_replies": "Posts and replies",
@ -197,9 +198,9 @@
"disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.", "disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.",
"dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.", "dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.",
"dismissable_banner.dismiss": "Dismiss", "dismissable_banner.dismiss": "Dismiss",
"dismissable_banner.explore_links": "These news stories are being talked about by people on this and other servers of the decentralized network right now.", "dismissable_banner.explore_links": "These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.",
"dismissable_banner.explore_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.", "dismissable_banner.explore_statuses": "These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.",
"dismissable_banner.explore_tags": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.", "dismissable_banner.explore_tags": "These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.",
"dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.", "dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.",
"embed.instructions": "Embed this post on your website by copying the code below.", "embed.instructions": "Embed this post on your website by copying the code below.",
"embed.preview": "Here is what it will look like:", "embed.preview": "Here is what it will look like:",
@ -232,8 +233,7 @@
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.", "empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
"empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.", "empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}", "empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
"empty_column.home.suggestions": "See some suggestions",
"empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.", "empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.", "empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.", "empty_column.mutes": "You haven't muted any users yet.",
@ -292,9 +292,13 @@
"hashtag.column_settings.tag_toggle": "Include additional tags for this column", "hashtag.column_settings.tag_toggle": "Include additional tags for this column",
"hashtag.follow": "Follow hashtag", "hashtag.follow": "Follow hashtag",
"hashtag.unfollow": "Unfollow hashtag", "hashtag.unfollow": "Unfollow hashtag",
"home.actions.go_to_explore": "See what's trending",
"home.actions.go_to_suggestions": "Find people to follow",
"home.column_settings.basic": "Basic", "home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies", "home.column_settings.show_replies": "Show replies",
"home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:",
"home.explore_prompt.title": "This is your home base within Mastodon.",
"home.hide_announcements": "Hide announcements", "home.hide_announcements": "Hide announcements",
"home.show_announcements": "Show announcements", "home.show_announcements": "Show announcements",
"interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.", "interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.",
@ -449,28 +453,27 @@
"notifications_permission_banner.title": "Never miss a thing", "notifications_permission_banner.title": "Never miss a thing",
"onboarding.action.back": "Take me back", "onboarding.action.back": "Take me back",
"onboarding.actions.back": "Take me back", "onboarding.actions.back": "Take me back",
"onboarding.actions.close": "Don't show this screen again", "onboarding.actions.go_to_explore": "Take me to trending",
"onboarding.actions.go_to_explore": "See what's trending", "onboarding.actions.go_to_home": "Take me to my home feed",
"onboarding.actions.go_to_home": "Go to your home feed",
"onboarding.compose.template": "Hello #Mastodon!", "onboarding.compose.template": "Hello #Mastodon!",
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.", "onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!", "onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
"onboarding.follows.title": "Popular on Mastodon", "onboarding.follows.title": "Personalize your home feed",
"onboarding.share.lead": "Let people know how they can find you on Mastodon!", "onboarding.share.lead": "Let people know how they can find you on Mastodon!",
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}", "onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
"onboarding.share.next_steps": "Possible next steps:", "onboarding.share.next_steps": "Possible next steps:",
"onboarding.share.title": "Share your profile", "onboarding.share.title": "Share your profile",
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", "onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:",
"onboarding.start.skip": "Want to skip right ahead?", "onboarding.start.skip": "Don't need help getting started?",
"onboarding.start.title": "You've made it!", "onboarding.start.title": "You've made it!",
"onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.", "onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
"onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow", "onboarding.steps.follow_people.title": "Personalize your home feed",
"onboarding.steps.publish_status.body": "Say hello to the world.", "onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}",
"onboarding.steps.publish_status.title": "Make your first post", "onboarding.steps.publish_status.title": "Make your first post",
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", "onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.",
"onboarding.steps.setup_profile.title": "Customize your profile", "onboarding.steps.setup_profile.title": "Personalize your profile",
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!", "onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon",
"onboarding.steps.share_profile.title": "Share your profile", "onboarding.steps.share_profile.title": "Share your Mastodon profile",
"onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!", "onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
"onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!", "onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!",
"onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!", "onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",

View File

@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => { ], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested); return hidden && !(isSelf || followingOrRequested);
}); });
export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList());

View File

@ -653,11 +653,6 @@ html {
border: 1px solid lighten($ui-base-color, 8%); border: 1px solid lighten($ui-base-color, 8%);
} }
.dismissable-banner {
border-left: 1px solid lighten($ui-base-color, 8%);
border-right: 1px solid lighten($ui-base-color, 8%);
}
.status__content, .status__content,
.reply-indicator__content { .reply-indicator__content {
a { a {

View File

@ -1514,12 +1514,37 @@ body > [data-popper-placement] {
} }
&__note { &__note {
font-size: 14px;
font-weight: 400;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
color: $ui-secondary-color; margin-top: 10px;
color: $darker-text-color;
&--missing {
color: $dark-text-color;
}
p {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
a {
color: inherit;
&:hover,
&:focus,
&:active {
text-decoration: none;
}
}
} }
} }
@ -2617,13 +2642,15 @@ $ui-header-height: 55px;
.onboarding__link { .onboarding__link {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 10px; gap: 10px;
color: $highlight-text-color; color: $highlight-text-color;
background: lighten($ui-base-color, 4%); background: lighten($ui-base-color, 4%);
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px 15px;
box-sizing: border-box; box-sizing: border-box;
font-size: 17px; font-size: 14px;
font-weight: 500;
height: 56px; height: 56px;
text-decoration: none; text-decoration: none;
@ -2685,6 +2712,7 @@ $ui-header-height: 55px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 10px; padding: 10px;
padding-inline-end: 15px;
margin-bottom: 2px; margin-bottom: 2px;
text-decoration: none; text-decoration: none;
text-align: start; text-align: start;
@ -2697,14 +2725,14 @@ $ui-header-height: 55px;
&__icon { &__icon {
flex: 0 0 auto; flex: 0 0 auto;
background: $ui-base-color;
border-radius: 50%; border-radius: 50%;
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 36px; width: 36px;
height: 36px; height: 36px;
color: $dark-text-color; color: $highlight-text-color;
font-size: 1.2rem;
@media screen and (width >= 600px) { @media screen and (width >= 600px) {
display: flex; display: flex;
@ -2728,16 +2756,33 @@ $ui-header-height: 55px;
} }
} }
&__go {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 21px;
height: 21px;
color: $highlight-text-color;
font-size: 17px;
svg {
height: 1.5em;
width: auto;
}
}
&__description { &__description {
flex: 1 1 auto; flex: 1 1 auto;
line-height: 18px; line-height: 20px;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
h6 { h6 {
color: $primary-text-color; color: $highlight-text-color;
font-weight: 700; font-weight: 500;
font-size: 14px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@ -8695,27 +8740,71 @@ noscript {
} }
.dismissable-banner { .dismissable-banner {
background: $ui-base-color; position: relative;
border-bottom: 1px solid lighten($ui-base-color, 8%); margin: 10px;
display: flex; margin-bottom: 5px;
align-items: center; border-radius: 8px;
gap: 30px; border: 1px solid $highlight-text-color;
background: rgba($highlight-text-color, 0.15);
padding-inline-end: 45px;
overflow: hidden;
&__background-image {
width: 125%;
position: absolute;
bottom: -25%;
inset-inline-end: -25%;
z-index: -1;
opacity: 0.15;
mix-blend-mode: luminosity;
}
&__message { &__message {
flex: 1 1 auto; flex: 1 1 auto;
padding: 20px 15px; padding: 15px;
cursor: default; font-size: 15px;
font-size: 14px; line-height: 22px;
line-height: 18px; font-weight: 500;
color: $primary-text-color; color: $primary-text-color;
p {
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
}
h1 {
color: $highlight-text-color;
font-size: 22px;
line-height: 33px;
font-weight: 700;
margin-bottom: 15px;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 30px;
}
.button-tertiary {
background: rgba($ui-base-color, 0.15);
backdrop-filter: blur(8px);
}
} }
&__action { &__action {
padding: 15px; position: absolute;
flex: 0 0 auto; inset-inline-end: 0;
display: flex; top: 0;
align-items: center; padding: 10px;
justify-content: center;
.icon-button {
color: $highlight-text-color;
}
} }
} }

View File

@ -28,8 +28,9 @@ class RequestPool
end end
MAX_IDLE_TIME = 30 MAX_IDLE_TIME = 30
WAIT_TIMEOUT = 5
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
REAPER_FREQUENCY = 30
WAIT_TIMEOUT = 5
class Connection class Connection
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
@ -98,7 +99,7 @@ class RequestPool
def initialize def initialize
@pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) } @pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) }
@reaper = Reaper.new(self, 30) @reaper = Reaper.new(self, REAPER_FREQUENCY)
@reaper.run @reaper.run
end end

View File

@ -79,7 +79,7 @@ class TextFormatter
cutoff = url[prefix.length..-1].length > 30 cutoff = url[prefix.length..-1].length > 30
<<~HTML.squish <<~HTML.squish
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a> <a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
HTML HTML
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(entity[:url]) h(entity[:url])
@ -122,7 +122,7 @@ class TextFormatter
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
<<~HTML.squish <<~HTML.squish
<span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span> <span class="h-card" translate="no"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
HTML HTML
end end

View File

@ -32,14 +32,8 @@ class AccountConversation < ApplicationRecord
end end
def participant_accounts def participant_accounts
@participant_accounts ||= begin @participant_accounts ||= Account.where(id: participant_account_ids).to_a
if participant_account_ids.empty? @participant_accounts.presence || [account]
[account]
else
participants = Account.where(id: participant_account_ids).to_a
participants.empty? ? [account] : participants
end
end
end end
class << self class << self

View File

@ -15,7 +15,7 @@ class UserSettings
setting :show_application, default: true setting :show_application, default: true
setting :default_language, default: nil setting :default_language, default: nil
setting :default_sensitive, default: false setting :default_sensitive, default: false
setting :default_privacy, default: nil setting :default_privacy, default: nil, in: %w(public unlisted private)
setting :default_content_type, default: 'text/plain' setting :default_content_type, default: 'text/plain'
setting :hide_followers_count, default: false setting :hide_followers_count, default: false
@ -79,7 +79,10 @@ class UserSettings
raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key) raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
typecast_value = self.class.definition_for(key).type_cast(value) setting_definition = self.class.definition_for(key)
typecast_value = setting_definition.type_cast(value)
raise ArgumentError, "Invalid value for setting #{key}: #{typecast_value}" if setting_definition.in.present? && setting_definition.in.exclude?(typecast_value)
if typecast_value.nil? if typecast_value.nil?
@original_hash.delete(key) @original_hash.delete(key)

View File

@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
status.updated status.updated
).freeze ).freeze
attr_writer :current_account
scope :enabled, -> { where(enabled: true) } scope :enabled, -> { where(enabled: true) }
validates :url, presence: true, url: true validates :url, presence: true, url: true
@ -31,6 +33,7 @@ class Webhook < ApplicationRecord
validates :events, presence: true validates :events, presence: true
validate :validate_events validate :validate_events
validate :validate_permissions
validate :validate_template validate :validate_template
before_validation :strip_events before_validation :strip_events
@ -48,12 +51,31 @@ class Webhook < ApplicationRecord
update!(enabled: false) update!(enabled: false)
end end
def required_permissions
events.map { |event| Webhook.permission_for_event(event) }
end
def self.permission_for_event(event)
case event
when 'account.approved', 'account.created', 'account.updated'
:manage_users
when 'report.created'
:manage_reports
when 'status.created', 'status.updated'
:view_devops
end
end
private private
def validate_events def validate_events
errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) } errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
end end
def validate_permissions
errors.add(:events, :invalid_permissions) if defined?(@current_account) && required_permissions.any? { |permission| !@current_account.user_role.can?(permission) }
end
def validate_template def validate_template
return if template.blank? return if template.blank?

View File

@ -14,7 +14,7 @@ class WebhookPolicy < ApplicationPolicy
end end
def update? def update?
role.can?(:manage_webhooks) role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end end
def enable? def enable?
@ -30,6 +30,6 @@ class WebhookPolicy < ApplicationPolicy
end end
def destroy? def destroy?
role.can?(:manage_webhooks) role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end end
end end

View File

@ -12,6 +12,7 @@ class RemoveStatusService < BaseService
# @option [Boolean] :immediate # @option [Boolean] :immediate
# @option [Boolean] :preserve # @option [Boolean] :preserve
# @option [Boolean] :original_removed # @option [Boolean] :original_removed
# @option [Boolean] :skip_streaming
def call(status, **options) def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s) @payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status @status = status
@ -53,6 +54,9 @@ class RemoveStatusService < BaseService
private private
# The following FeedManager calls all do not result in redis publishes for
# streaming, as the `:update` option is false
def remove_from_self def remove_from_self
FeedManager.instance.unpush_from_home(@account, @status) FeedManager.instance.unpush_from_home(@account, @status)
FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility? FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility?
@ -77,6 +81,8 @@ class RemoveStatusService < BaseService
# followers. Here we send a delete to actively mentioned accounts # followers. Here we send a delete to actively mentioned accounts
# that may not follow the account # that may not follow the account
return if skip_streaming?
@status.active_mentions.find_each do |mention| @status.active_mentions.find_each do |mention|
redis.publish("timeline:#{mention.account_id}", @payload) redis.publish("timeline:#{mention.account_id}", @payload)
end end
@ -105,7 +111,7 @@ class RemoveStatusService < BaseService
# without us being able to do all the fancy stuff # without us being able to do all the fancy stuff
@status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog| @status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog|
RemoveStatusService.new.call(reblog, original_removed: true) RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?)
end end
end end
@ -116,6 +122,8 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility? return unless @status.public_visibility?
return if skip_streaming?
@status.tags.map(&:name).each do |hashtag| @status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local? redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
@ -125,6 +133,8 @@ class RemoveStatusService < BaseService
def remove_from_public def remove_from_public
return unless @status.public_visibility? return unless @status.public_visibility?
return if skip_streaming?
redis.publish('timeline:public', @payload) redis.publish('timeline:public', @payload)
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload) redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
end end
@ -132,6 +142,8 @@ class RemoveStatusService < BaseService
def remove_from_media def remove_from_media
return unless @status.public_visibility? return unless @status.public_visibility?
return if skip_streaming?
redis.publish('timeline:public:media', @payload) redis.publish('timeline:public:media', @payload)
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload) redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
end end
@ -151,4 +163,8 @@ class RemoveStatusService < BaseService
def permanently? def permanently?
@options[:immediate] || !(@options[:preserve] || @status.reported?) @options[:immediate] || !(@options[:preserve] || @status.reported?)
end end
def skip_streaming?
!!@options[:skip_streaming]
end
end end

View File

@ -5,7 +5,7 @@
= f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' } = f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
.fields-group .fields-group
= f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) }
.fields-group .fields-group
= f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' } = f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }

View File

@ -24,7 +24,7 @@ class Scheduler::UserCleanupScheduler
def clean_discarded_statuses! def clean_discarded_statuses!
Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses| Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
RemovalWorker.push_bulk(statuses) do |status| RemovalWorker.push_bulk(statuses) do |status|
[status.id, { 'immediate' => true }] [status.id, { 'immediate' => true, 'skip_streaming' => true }]
end end
end end
end end

View File

@ -6,12 +6,4 @@ end
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup' # Set up gems listed in the Gemfile. require 'bundler/setup' # Set up gems listed in the Gemfile.
require 'bootsnap' # Speed up boot time by caching expensive operations. require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
Bootsnap.setup(
cache_dir: File.expand_path('../tmp/cache', __dir__),
development_mode: ENV.fetch('RAILS_ENV', 'development') == 'development',
load_path_cache: true,
compile_cache_iseq: false,
compile_cache_yaml: false
)

View File

@ -53,3 +53,7 @@ en:
position: position:
elevated: cannot be higher than your current role elevated: cannot be higher than your current role
own_role: cannot be changed with your current role own_role: cannot be changed with your current role
webhook:
attributes:
events:
invalid_permissions: cannot include events you don't have the rights to

View File

@ -82,6 +82,7 @@ namespace :api, format: false do
resources :conversations, only: [:index, :destroy] do resources :conversations, only: [:index, :destroy] do
member do member do
post :read post :read
post :unread
end end
end end

View File

@ -55,6 +55,11 @@ class Sanitize
end end
end end
TRANSLATE_TRANSFORMER = lambda do |env|
node = env[:node]
node.remove_attribute('translate') unless node['translate'] == 'no'
end
UNSUPPORTED_HREF_TRANSFORMER = lambda do |env| UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
return unless env[:node_name] == 'a' return unless env[:node_name] == 'a'
@ -73,9 +78,9 @@ class Sanitize
elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li), elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
attributes: { attributes: {
'a' => %w(href rel class title), 'a' => %w(href rel class title translate),
'abbr' => %w(title), 'abbr' => %w(title),
'span' => %w(class), 'span' => %w(class translate),
'blockquote' => %w(cite), 'blockquote' => %w(cite),
'ol' => %w(start reversed), 'ol' => %w(start reversed),
'li' => %w(value), 'li' => %w(value),
@ -96,6 +101,7 @@ class Sanitize
transformers: [ transformers: [
CLASS_WHITELIST_TRANSFORMER, CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER, IMG_TAG_TRANSFORMER,
TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER,
] ]
) )
@ -151,7 +157,7 @@ class Sanitize
MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge( MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
attributes: merge( attributes: merge(
MASTODON_STRICT[:attributes], MASTODON_STRICT[:attributes],
'a' => %w(href rel class title target) 'a' => %w(href rel class title target translate)
), ),
add_attributes: {}, add_attributes: {},
@ -159,6 +165,7 @@ class Sanitize
transformers: [ transformers: [
CLASS_WHITELIST_TRANSFORMER, CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER, IMG_TAG_TRANSFORMER,
TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER, UNSUPPORTED_HREF_TRANSFORMER,
LINK_REL_TRANSFORMER, LINK_REL_TRANSFORMER,
LINK_TARGET_TRANSFORMER, LINK_TARGET_TRANSFORMER,

View File

@ -23,7 +23,8 @@ RSpec.describe Admin::ChangeEmailsController do
describe 'GET #update' do describe 'GET #update' do
before do before do
allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil)) allow(UserMailer).to receive(:confirmation_instructions)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
end end
it 'returns http success' do it 'returns http success' do

View File

@ -38,7 +38,7 @@ RSpec.describe Admin::ConfirmationsController do
let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) } let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
before do before do
allow(UserMailer).to receive(:confirmation_instructions) { double(:email, deliver_later: nil) } allow(UserMailer).to receive(:confirmation_instructions) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
end end
context 'when email is not confirmed' do context 'when email is not confirmed' do

View File

@ -19,7 +19,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
before do before do
allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil)) allow(UserMailer).to receive(:appeal_approved)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :approve, params: { id: appeal.id } post :approve, params: { id: appeal.id }
end end
@ -40,7 +41,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
before do before do
allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil)) allow(UserMailer).to receive(:appeal_rejected)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :reject, params: { id: appeal.id } post :reject, params: { id: appeal.id }
end end

View File

@ -37,7 +37,7 @@ RSpec.describe Admin::DomainAllowsController do
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
it 'disallows the domain' do it 'disallows the domain' do
service = double(call: true) service = instance_double(UnallowDomainService, call: true)
allow(UnallowDomainService).to receive(:new).and_return(service) allow(UnallowDomainService).to receive(:new).and_return(service)
domain_allow = Fabricate(:domain_allow) domain_allow = Fabricate(:domain_allow)
delete :destroy, params: { id: domain_allow.id } delete :destroy, params: { id: domain_allow.id }

View File

@ -213,7 +213,7 @@ RSpec.describe Admin::DomainBlocksController do
describe 'DELETE #destroy' do describe 'DELETE #destroy' do
it 'unblocks the domain' do it 'unblocks the domain' do
service = double(call: true) service = instance_double(UnblockDomainService, call: true)
allow(UnblockDomainService).to receive(:new).and_return(service) allow(UnblockDomainService).to receive(:new).and_return(service)
domain_block = Fabricate(:domain_block) domain_block = Fabricate(:domain_block)
delete :destroy, params: { id: domain_block.id } delete :destroy, params: { id: domain_block.id }

View File

@ -62,17 +62,10 @@ describe Admin::Reports::ActionsController do
end end
shared_examples 'common behavior' do shared_examples 'common behavior' do
it 'closes the report' do it 'closes the report and redirects' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true) expect { subject }.to mark_report_action_taken.and create_target_account_strike
end
it 'creates a strike with the expected text' do
expect { subject }.to change { report.target_account.strikes.count }.by(1)
expect(report.target_account.strikes.last.text).to eq text expect(report.target_account.strikes.last.text).to eq text
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path) expect(response).to redirect_to(admin_reports_path)
end end
@ -81,20 +74,21 @@ describe Admin::Reports::ActionsController do
{ report_id: report.id } { report_id: report.id }
end end
it 'closes the report' do it 'closes the report and redirects' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true) expect { subject }.to mark_report_action_taken.and create_target_account_strike
end
it 'creates a strike with the expected text' do
expect { subject }.to change { report.target_account.strikes.count }.by(1)
expect(report.target_account.strikes.last.text).to eq '' expect(report.target_account.strikes.last.text).to eq ''
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path) expect(response).to redirect_to(admin_reports_path)
end end
end end
def mark_report_action_taken
change { report.reload.action_taken? }.from(false).to(true)
end
def create_target_account_strike
change { report.target_account.strikes.count }.by(1)
end
end end
shared_examples 'all action types' do shared_examples 'all action types' do

View File

@ -48,7 +48,7 @@ describe Admin::WebhooksController do
end end
context 'with an existing record' do context 'with an existing record' do
let!(:webhook) { Fabricate :webhook } let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
describe 'GET #show' do describe 'GET #show' do
it 'returns http success and renders view' do it 'returns http success and renders view' do
@ -82,7 +82,7 @@ describe Admin::WebhooksController do
end.to_not change(webhook, :url) end.to_not change(webhook, :url)
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
expect(response).to render_template(:show) expect(response).to render_template(:edit)
end end
end end

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::Admin::AccountActionsController do
render_views
let(:role) { UserRole.find_by(name: 'Moderator') }
let(:user) { Fabricate(:user, role: role) }
let(:scopes) { 'admin:read admin:write' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:account) { Fabricate(:account) }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'POST #create' do
context 'with type of disable' do
before do
post :create, params: { account_id: account.id, type: 'disable' }
end
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
it_behaves_like 'forbidden for wrong role', ''
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'performs action against account' do
expect(account.reload.user_disabled?).to be true
end
it 'logs action' do
log_item = Admin::ActionLog.last
expect(log_item).to_not be_nil
expect(log_item.action).to eq :disable
expect(log_item.account_id).to eq user.account_id
expect(log_item.target_id).to eq account.user.id
end
end
context 'with no type' do
before do
post :create, params: { account_id: account.id }
end
it 'returns http unprocessable entity' do
expect(response).to have_http_status(422)
end
end
end
end

View File

@ -18,6 +18,7 @@ RSpec.describe Api::V1::ConversationsController do
before do before do
PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct') PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct')
end end
it 'returns http success' do it 'returns http success' do
@ -33,7 +34,8 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do it 'returns conversations' do
get :index get :index
json = body_as_json json = body_as_json
expect(json.size).to eq 1 expect(json.size).to eq 2
expect(json[0][:accounts].size).to eq 1
end end
context 'with since_id' do context 'with since_id' do
@ -41,7 +43,7 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do it 'returns conversations' do
get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) } get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
json = body_as_json json = body_as_json
expect(json.size).to eq 1 expect(json.size).to eq 2
end end
end end

View File

@ -67,24 +67,13 @@ RSpec.describe Api::V1::NotificationsController do
get :index get :index
end end
it 'returns http success' do it 'returns expected notification types', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'includes reblog' do expect(body_json_types).to include 'reblog'
expect(body_as_json.pluck(:type)).to include 'reblog' expect(body_json_types).to include 'mention'
end expect(body_json_types).to include 'favourite'
expect(body_json_types).to include 'follow'
it 'includes mention' do
expect(body_as_json.pluck(:type)).to include 'mention'
end
it 'includes favourite' do
expect(body_as_json.pluck(:type)).to include 'favourite'
end
it 'includes follow' do
expect(body_as_json.pluck(:type)).to include 'follow'
end end
end end
@ -93,12 +82,14 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: third.account.id } get :index, params: { account_id: third.account.id }
end end
it 'returns http success' do it 'returns only notifications from specified user', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(body_json_account_ids.uniq).to eq [third.account.id.to_s]
end end
it 'returns only notifications from specified user' do def body_json_account_ids
expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s] body_as_json.map { |x| x[:account][:id] }
end end
end end
@ -107,27 +98,23 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: 'foo' } get :index, params: { account_id: 'foo' }
end end
it 'returns http success' do it 'returns nothing', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns nothing' do
expect(body_as_json.size).to eq 0 expect(body_as_json.size).to eq 0
end end
end end
describe 'with excluded_types param' do describe 'with exclude_types param' do
before do before do
get :index, params: { exclude_types: %w(mention) } get :index, params: { exclude_types: %w(mention) }
end end
it 'returns http success' do it 'returns everything but excluded type', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns everything but excluded type' do
expect(body_as_json.size).to_not eq 0 expect(body_as_json.size).to_not eq 0
expect(body_as_json.pluck(:type).uniq).to_not include 'mention' expect(body_json_types.uniq).to_not include 'mention'
end end
end end
@ -136,13 +123,15 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { types: %w(mention) } get :index, params: { types: %w(mention) }
end end
it 'returns http success' do it 'returns only requested type', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(body_json_types.uniq).to eq ['mention']
end
end end
it 'returns only requested type' do def body_json_types
expect(body_as_json.pluck(:type).uniq).to eq ['mention'] body_as_json.pluck(:type)
end
end end
end end
end end

View File

@ -23,7 +23,8 @@ RSpec.describe Api::V1::ReportsController do
let(:rule_ids) { nil } let(:rule_ids) { nil }
before do before do
allow(AdminMailer).to receive(:new_report).and_return(double('email', deliver_later: nil)) allow(AdminMailer).to receive(:new_report)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward } post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
end end

View File

@ -23,6 +23,7 @@ describe Api::V1::Statuses::HistoriesController do
it 'returns http success' do it 'returns http success' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
expect(body_as_json.size).to_not be 0
end end
end end
end end

View File

@ -1,37 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::SuggestionsController do
render_views
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
describe 'GET #index' do
let(:bob) { Fabricate(:account) }
let(:jeff) { Fabricate(:account) }
before do
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
get :index
end
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns accounts' do
json = body_as_json
expect(json.size).to be >= 1
expect(json.pluck(:id)).to include(*[bob, jeff].map { |i| i.id.to_s })
end
end
end

View File

@ -1,88 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::TagsController do
render_views
let(:user) { Fabricate(:user) }
let(:scopes) { 'write:follows' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
before { allow(controller).to receive(:doorkeeper_token) { token } }
describe 'GET #show' do
before do
get :show, params: { id: name }
end
context 'with existing tag' do
let!(:tag) { Fabricate(:tag) }
let(:name) { tag.name }
it 'returns http success' do
expect(response).to have_http_status(:success)
end
end
context 'with non-existing tag' do
let(:name) { 'hoge' }
it 'returns http success' do
expect(response).to have_http_status(:success)
end
end
end
describe 'POST #follow' do
let!(:unrelated_tag) { Fabricate(:tag) }
before do
TagFollow.create!(account: user.account, tag: unrelated_tag)
post :follow, params: { id: name }
end
context 'with existing tag' do
let!(:tag) { Fabricate(:tag) }
let(:name) { tag.name }
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'creates follow' do
expect(TagFollow.where(tag: tag, account: user.account).exists?).to be true
end
end
context 'with non-existing tag' do
let(:name) { 'hoge' }
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'creates follow' do
expect(TagFollow.where(tag: Tag.find_by!(name: name), account: user.account).exists?).to be true
end
end
end
describe 'POST #unfollow' do
let!(:tag) { Fabricate(:tag, name: 'foo') }
let!(:tag_follow) { Fabricate(:tag_follow, account: user.account, tag: tag) }
before do
post :unfollow, params: { id: tag.name }
end
it 'returns http success' do
expect(response).to have_http_status(:success)
end
it 'removes the follow' do
expect(TagFollow.where(tag: tag, account: user.account).exists?).to be false
end
end
end

View File

@ -55,5 +55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do
end end
end end
end end
context 'with limit param' do
let(:params) { { limit: 1 } }
it 'sets the correct pagination headers' do
expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v2_admin_accounts_url(limit: 1, max_id: admin_account.id)
end
end
end end
end end

View File

@ -26,7 +26,7 @@ describe Api::Web::EmbedsController do
context 'when fails to find status' do context 'when fails to find status' do
let(:url) { 'https://host.test/oembed.html' } let(:url) { 'https://host.test/oembed.html' }
let(:service_instance) { double('fetch_oembed_service') } let(:service_instance) { instance_double(FetchOEmbedService) }
before do before do
allow(FetchOEmbedService).to receive(:new) { service_instance } allow(FetchOEmbedService).to receive(:new) { service_instance }

View File

@ -127,7 +127,8 @@ RSpec.describe Auth::SessionsController do
before do before do
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip) allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip)
allow(UserMailer).to receive(:suspicious_sign_in).and_return(double('email', deliver_later!: nil)) allow(UserMailer).to receive(:suspicious_sign_in)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later!: nil))
user.update(current_sign_in_at: 1.month.ago) user.update(current_sign_in_at: 1.month.ago)
post :create, params: { user: { email: user.email, password: user.password } } post :create, params: { user: { email: user.email, password: user.password } }
end end

View File

@ -28,7 +28,7 @@ describe AuthorizeInteractionsController do
end end
it 'renders error when account cant be found' do it 'renders error when account cant be found' do
service = double service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('missing@hostname').and_return(nil) allow(service).to receive(:call).with('missing@hostname').and_return(nil)
@ -40,7 +40,7 @@ describe AuthorizeInteractionsController do
it 'sets resource from url' do it 'sets resource from url' do
account = Fabricate(:account) account = Fabricate(:account)
service = double service = instance_double(ResolveURLService)
allow(ResolveURLService).to receive(:new).and_return(service) allow(ResolveURLService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('http://example.com').and_return(account) allow(service).to receive(:call).with('http://example.com').and_return(account)
@ -52,7 +52,7 @@ describe AuthorizeInteractionsController do
it 'sets resource from acct uri' do it 'sets resource from acct uri' do
account = Fabricate(:account) account = Fabricate(:account)
service = double service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('found@hostname').and_return(account) allow(service).to receive(:call).with('found@hostname').and_return(account)
@ -82,7 +82,7 @@ describe AuthorizeInteractionsController do
end end
it 'shows error when account not found' do it 'shows error when account not found' do
service = double service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(nil) allow(service).to receive(:call).with('user@hostname').and_return(nil)
@ -94,7 +94,7 @@ describe AuthorizeInteractionsController do
it 'follows account when found' do it 'follows account when found' do
target_account = Fabricate(:account) target_account = Fabricate(:account)
service = double service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(target_account) allow(service).to receive(:call).with('user@hostname').and_return(target_account)

View File

@ -14,7 +14,8 @@ RSpec.describe Disputes::AppealsController do
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) } let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
before do before do
allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil)) allow(AdminMailer).to receive(:new_appeal)
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } } post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
end end

View File

@ -75,23 +75,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public' expect(response.headers['Cache-Control']).to include 'public'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -100,25 +88,13 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it_behaves_like 'cacheable response' it_behaves_like 'cacheable response'
it 'returns Content-Type header' do it 'renders ActivityPub Note object successfully', :aggregate_failures do
expect(response).to have_http_status(200)
expect(response.headers['Link'].to_s).to include 'activity+json'
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -199,23 +175,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -224,27 +188,12 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do it 'renders ActivityPub Note object successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -263,23 +212,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -288,27 +225,12 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do it 'renders ActivityPub Note object successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -350,23 +272,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -375,27 +285,12 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do it 'renders ActivityPub Note object successfully' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -463,23 +358,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -488,25 +371,13 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do
expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it_behaves_like 'cacheable response' it_behaves_like 'cacheable response'
it 'returns Content-Type header' do it 'renders ActivityPub Note object successfully', :aggregate_failures do
expect(response).to have_http_status(200)
expect(response.headers['Link'].to_s).to include 'activity+json'
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -525,23 +396,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -550,27 +409,12 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do it 'renders ActivityPub Note object successfully' do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -612,23 +456,11 @@ describe StatusesController do
context 'with HTML' do context 'with HTML' do
let(:format) { 'html' } let(:format) { 'html' }
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show) expect(response).to render_template(:show)
expect(response.body).to include status.text expect(response.body).to include status.text
end end
@ -637,27 +469,12 @@ describe StatusesController do
context 'with JSON' do context 'with JSON' do
let(:format) { 'json' } let(:format) { 'json' }
it 'returns http success' do it 'renders ActivityPub Note object', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private' expect(response.headers['Cache-Control']).to include 'private'
end
it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json' expect(response.headers['Content-Type']).to include 'application/activity+json'
end
it 'renders ActivityPub Note object' do
json = body_as_json json = body_as_json
expect(json[:content]).to include status.text expect(json[:content]).to include status.text
end end
@ -933,23 +750,11 @@ describe StatusesController do
get :embed, params: { account_username: status.account.username, id: status.id } get :embed, params: { account_username: status.account.username, id: status.id }
end end
it 'returns http success' do it 'renders status successfully', :aggregate_failures do
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end
it 'returns Link header' do
expect(response.headers['Link'].to_s).to include 'activity+json' expect(response.headers['Link'].to_s).to include 'activity+json'
end
it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie' expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
end
it 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public' expect(response.headers['Cache-Control']).to include 'public'
end
it 'renders status' do
expect(response).to render_template(:embed) expect(response).to render_template(:embed)
expect(response.body).to include status.text expect(response.body).to include status.text
end end

View File

@ -117,42 +117,42 @@ describe StatusesHelper do
describe '#style_classes' do describe '#style_classes' do
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, false) classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry' expect(classes).to eq 'entry'
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, false, false, false) classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry entry-reblog' expect(classes).to eq 'entry entry-reblog'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, true, false, false) classes = helper.style_classes(status, true, false, false)
expect(classes).to eq 'entry entry-predecessor' expect(classes).to eq 'entry entry-predecessor'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, true, false) classes = helper.style_classes(status, false, true, false)
expect(classes).to eq 'entry entry-successor' expect(classes).to eq 'entry entry-successor'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, true) classes = helper.style_classes(status, false, false, true)
expect(classes).to eq 'entry entry-center' expect(classes).to eq 'entry entry-center'
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, true, true, true) classes = helper.style_classes(status, true, true, true)
expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center' expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
@ -161,35 +161,35 @@ describe StatusesHelper do
describe '#microformats_classes' do describe '#microformats_classes' do
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, false) classes = helper.microformats_classes(status, false, false)
expect(classes).to eq '' expect(classes).to eq ''
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, true, false) classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to' expect(classes).to eq 'p-in-reply-to'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, true) classes = helper.microformats_classes(status, false, true)
expect(classes).to eq 'p-comment' expect(classes).to eq 'p-comment'
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, false) classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to p-repost-of' expect(classes).to eq 'p-in-reply-to p-repost-of'
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, true) classes = helper.microformats_classes(status, true, true)
expect(classes).to eq 'p-in-reply-to p-repost-of p-comment' expect(classes).to eq 'p-in-reply-to p-repost-of p-comment'
@ -198,42 +198,42 @@ describe StatusesHelper do
describe '#microformats_h_class' do describe '#microformats_h_class' do
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, false) css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-entry' expect(css_class).to eq 'h-entry'
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, false, false, false) css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-cite' expect(css_class).to eq 'h-cite'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, true, false, false) css_class = helper.microformats_h_class(status, true, false, false)
expect(css_class).to eq 'h-cite' expect(css_class).to eq 'h-cite'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, true, false) css_class = helper.microformats_h_class(status, false, true, false)
expect(css_class).to eq 'h-cite' expect(css_class).to eq 'h-cite'
end end
it do it do
status = double(reblog?: false) status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, true) css_class = helper.microformats_h_class(status, false, false, true)
expect(css_class).to eq '' expect(css_class).to eq ''
end end
it do it do
status = double(reblog?: true) status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, true, true, true) css_class = helper.microformats_h_class(status, true, true, true)
expect(css_class).to eq 'h-cite' expect(css_class).to eq 'h-cite'

View File

@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do
end end
context 'when status was not known before' do context 'when status was not known before' do
let(:service_stub) { double } let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) }
let(:json) do let(:json) do
{ {

View File

@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do
stub_request(:post, old_account.inbox_url).to_return(status: 200) stub_request(:post, old_account.inbox_url).to_return(status: 200)
stub_request(:post, new_account.inbox_url).to_return(status: 200) stub_request(:post, new_account.inbox_url).to_return(status: 200)
service_stub = double service_stub = instance_double(ActivityPub::FetchRemoteAccountService)
allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub) allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub)
allow(service_stub).to receive(:call).and_return(returned_account) allow(service_stub).to receive(:call).and_return(returned_account)
end end

View File

@ -48,16 +48,25 @@ describe RequestPool do
expect(subject.size).to be > 1 expect(subject.size).to be > 1
end end
it 'closes idle connections' do context 'with an idle connection' do
before do
stub_const('RequestPool::MAX_IDLE_TIME', 1) # Lower idle time limit to 1 seconds
stub_const('RequestPool::REAPER_FREQUENCY', 0.1) # Run reaper every 0.1 seconds
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!') stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
end
it 'closes the connections' do
subject.with('http://example.com') do |http_client| subject.with('http://example.com') do |http_client|
http_client.get('/').flush http_client.get('/').flush
end end
expect(subject.size).to eq 1 expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0)
sleep RequestPool::MAX_IDLE_TIME + 30 + 1 end
expect(subject.size).to eq 0
def reaper_observes_idle_timeout
# One full idle period and 2 reaper cycles more
sleep RequestPool::MAX_IDLE_TIME + (RequestPool::REAPER_FREQUENCY * 2)
end
end end
end end
end end

View File

@ -48,7 +48,7 @@ describe Request do
end end
it 'executes a HTTP request when the first address is private' do it 'executes a HTTP request when the first address is private' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844)) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844))
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
@ -83,7 +83,7 @@ describe Request do
end end
it 'raises Mastodon::ValidationError' do it 'raises Mastodon::ValidationError' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face)) allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face))
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)

View File

@ -36,6 +36,14 @@ describe Sanitize::Config do
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>' expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
end end
it 'keeps a with translate="no"' do
expect(Sanitize.fragment('<a href="http://example.com" translate="no">Test</a>', subject)).to eq '<a href="http://example.com" translate="no" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
end
it 'removes "translate" attribute with invalid value' do
expect(Sanitize.fragment('<a href="http://example.com" translate="foo">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
end
it 'removes a with unparsable href' do it 'removes a with unparsable href' do
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test' expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
end end

View File

@ -7,7 +7,7 @@ RSpec.describe SuspiciousSignInDetector do
subject { described_class.new(user).suspicious?(request) } subject { described_class.new(user).suspicious?(request) }
let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) } let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) }
let(:request) { double(remote_ip: remote_ip) } let(:request) { instance_double(ActionDispatch::Request, remote_ip: remote_ip) }
let(:remote_ip) { nil } let(:remote_ip) { nil }
context 'when user has 2FA enabled' do context 'when user has 2FA enabled' do

View File

@ -142,4 +142,59 @@ describe UserMailer do
expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title') expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title')
end end
end end
describe 'two_factor_enabled' do
let(:mail) { described_class.two_factor_enabled(receiver) }
it 'renders two_factor_enabled mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_enabled.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_enabled.explanation')
end
end
describe 'two_factor_disabled' do
let(:mail) { described_class.two_factor_disabled(receiver) }
it 'renders two_factor_disabled mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_disabled.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_disabled.explanation')
end
end
describe 'webauthn_enabled' do
let(:mail) { described_class.webauthn_enabled(receiver) }
it 'renders webauthn_enabled mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_enabled.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_enabled.explanation')
end
end
describe 'webauthn_disabled' do
let(:mail) { described_class.webauthn_disabled(receiver) }
it 'renders webauthn_disabled mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_disabled.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_disabled.explanation')
end
end
describe 'two_factor_recovery_codes_changed' do
let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) }
it 'renders two_factor_recovery_codes_changed mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation')
end
end
describe 'webauthn_credential_added' do
let(:credential) { Fabricate.build(:webauthn_credential) }
let(:mail) { described_class.webauthn_credential_added(receiver, credential) }
it 'renders webauthn_credential_added mail' do
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_credential.added.subject')
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.added.explanation')
end
end
end end

View File

@ -6,7 +6,7 @@ RSpec.describe Account::Field do
describe '#verified?' do describe '#verified?' do
subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) } subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) }
let(:account) { double('Account', local?: true) } let(:account) { instance_double(Account, local?: true) }
context 'when verified_at is set' do context 'when verified_at is set' do
let(:verified_at) { Time.now.utc.iso8601 } let(:verified_at) { Time.now.utc.iso8601 }
@ -28,7 +28,7 @@ RSpec.describe Account::Field do
describe '#mark_verified!' do describe '#mark_verified!' do
subject { described_class.new(account, original_hash) } subject { described_class.new(account, original_hash) }
let(:account) { double('Account', local?: true) } let(:account) { instance_double(Account, local?: true) }
let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } } let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } }
before do before do
@ -47,7 +47,7 @@ RSpec.describe Account::Field do
describe '#verifiable?' do describe '#verifiable?' do
subject { described_class.new(account, 'name' => 'Foo', 'value' => value) } subject { described_class.new(account, 'name' => 'Foo', 'value' => value) }
let(:account) { double('Account', local?: local) } let(:account) { instance_double(Account, local?: local) }
context 'with local accounts' do context 'with local accounts' do
let(:local) { true } let(:local) { true }

View File

@ -15,7 +15,7 @@ RSpec.describe AccountMigration do
before do before do
target_account.aliases.create!(acct: source_account.acct) target_account.aliases.create!(acct: source_account.acct)
service_double = double service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(ResolveAccountService).to receive(:new).and_return(service_double)
allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account) allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account)
end end
@ -29,7 +29,7 @@ RSpec.describe AccountMigration do
let(:target_acct) { 'target@remote' } let(:target_acct) { 'target@remote' }
before do before do
service_double = double service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service_double) allow(ResolveAccountService).to receive(:new).and_return(service_double)
allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil) allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil)
end end

View File

@ -16,7 +16,7 @@ RSpec.describe SessionActivation do
allow(session_activation).to receive(:detection).and_return(detection) allow(session_activation).to receive(:detection).and_return(detection)
end end
let(:detection) { double(id: 1) } let(:detection) { instance_double(Browser::Chrome, id: 1) }
let(:session_activation) { Fabricate(:session_activation) } let(:session_activation) { Fabricate(:session_activation) }
it 'returns detection.id' do it 'returns detection.id' do
@ -30,7 +30,7 @@ RSpec.describe SessionActivation do
end end
let(:session_activation) { Fabricate(:session_activation) } let(:session_activation) { Fabricate(:session_activation) }
let(:detection) { double(platform: double(id: 1)) } let(:detection) { instance_double(Browser::Chrome, platform: instance_double(Browser::Platform, id: 1)) }
it 'returns detection.platform.id' do it 'returns detection.platform.id' do
expect(session_activation.platform).to be 1 expect(session_activation.platform).to be 1

View File

@ -62,7 +62,7 @@ RSpec.describe Setting do
context 'when RailsSettings::Settings.object returns truthy' do context 'when RailsSettings::Settings.object returns truthy' do
let(:object) { db_val } let(:object) { db_val }
let(:db_val) { double(value: 'db_val') } let(:db_val) { instance_double(described_class, value: 'db_val') }
context 'when default_value is a Hash' do context 'when default_value is a Hash' do
let(:default_value) { { default_value: 'default_value' } } let(:default_value) { { default_value: 'default_value' } }

View File

@ -49,6 +49,16 @@ RSpec.describe UserSettings do
expect(subject[:always_send_emails]).to be true expect(subject[:always_send_emails]).to be true
end end
end end
context 'when the setting has a closed set of values' do
it 'updates the attribute when given a valid value' do
expect { subject[:'web.display_media'] = :show_all }.to change { subject[:'web.display_media'] }.from('default').to('show_all')
end
it 'raises an error when given an invalid value' do
expect { subject[:'web.display_media'] = 'invalid value' }.to raise_error ArgumentError
end
end
end end
describe '#update' do describe '#update' do

View File

@ -8,16 +8,32 @@ describe WebhookPolicy do
let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
let(:john) { Fabricate(:account) } let(:john) { Fabricate(:account) }
permissions :index?, :create?, :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do permissions :index?, :create? do
context 'with an admin' do context 'with an admin' do
it 'permits' do it 'permits' do
expect(policy).to permit(admin, Tag) expect(policy).to permit(admin, Webhook)
end end
end end
context 'with a non-admin' do context 'with a non-admin' do
it 'denies' do it 'denies' do
expect(policy).to_not permit(john, Tag) expect(policy).to_not permit(john, Webhook)
end
end
end
permissions :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do
let(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
context 'with an admin' do
it 'permits' do
expect(policy).to permit(admin, webhook)
end
end
context 'with a non-admin' do
it 'denies' do
expect(policy).to_not permit(john, webhook)
end end
end end
end end

View File

@ -0,0 +1,154 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Account actions' do
let(:role) { UserRole.find_by(name: 'Admin') }
let(:user) { Fabricate(:user, role: role) }
let(:scopes) { 'admin:write admin:write:accounts' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
let(:mailer) { instance_double(ActionMailer::MessageDelivery, deliver_later!: nil) }
before do
allow(UserMailer).to receive(:warning).with(target_account.user, anything).and_return(mailer)
end
shared_examples 'a successful notification delivery' do
it 'notifies the user about the action taken' do
subject
expect(UserMailer).to have_received(:warning).with(target_account.user, anything).once
expect(mailer).to have_received(:deliver_later!).once
end
end
shared_examples 'a successful logged action' do |action_type, target_type|
it 'logs action' do
subject
log_item = Admin::ActionLog.last
expect(log_item).to be_present
expect(log_item.action).to eq(action_type)
expect(log_item.account_id).to eq(user.account_id)
expect(log_item.target_id).to eq(target_type == :user ? target_account.user.id : target_account.id)
end
end
describe 'POST /api/v1/admin/accounts/:id/action' do
subject do
post "/api/v1/admin/accounts/#{target_account.id}/action", headers: headers, params: params
end
let(:target_account) { Fabricate(:account) }
context 'with type of disable' do
let(:params) { { type: 'disable' } }
it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
it_behaves_like 'forbidden for wrong role', ''
it_behaves_like 'a successful notification delivery'
it_behaves_like 'a successful logged action', :disable, :user
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'disables the target account' do
expect { subject }.to change { target_account.reload.user_disabled? }.from(false).to(true)
end
end
context 'with type of sensitive' do
let(:params) { { type: 'sensitive' } }
it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
it_behaves_like 'forbidden for wrong role', ''
it_behaves_like 'a successful notification delivery'
it_behaves_like 'a successful logged action', :sensitive, :account
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'marks the target account as sensitive' do
expect { subject }.to change { target_account.reload.sensitized? }.from(false).to(true)
end
end
context 'with type of silence' do
let(:params) { { type: 'silence' } }
it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
it_behaves_like 'forbidden for wrong role', ''
it_behaves_like 'a successful notification delivery'
it_behaves_like 'a successful logged action', :silence, :account
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'marks the target account as silenced' do
expect { subject }.to change { target_account.reload.silenced? }.from(false).to(true)
end
end
context 'with type of suspend' do
let(:params) { { type: 'suspend' } }
it_behaves_like 'forbidden for wrong scope', 'admin:read admin:read:accounts'
it_behaves_like 'forbidden for wrong role', ''
it_behaves_like 'a successful notification delivery'
it_behaves_like 'a successful logged action', :suspend, :account
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'marks the target account as suspended' do
expect { subject }.to change { target_account.reload.suspended? }.from(false).to(true)
end
end
context 'with type of none' do
let(:params) { { type: 'none' } }
it_behaves_like 'a successful notification delivery'
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
end
context 'with no type' do
let(:params) { {} }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end
end
context 'with invalid type' do
let(:params) { { type: 'invalid' } }
it 'returns http unprocessable entity' do
subject
expect(response).to have_http_status(422)
end
end
end
end

View File

@ -0,0 +1,103 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Suggestions' do
let(:user) { Fabricate(:user) }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:scopes) { 'read' }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v1/suggestions' do
subject do
get '/api/v1/suggestions', headers: headers, params: params
end
let(:bob) { Fabricate(:account) }
let(:jeff) { Fabricate(:account) }
let(:params) { {} }
before do
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
end
it_behaves_like 'forbidden for wrong scope', 'write'
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'returns accounts' do
subject
body = body_as_json
expect(body.size).to eq 2
expect(body.pluck(:id)).to match_array([bob, jeff].map { |i| i.id.to_s })
end
context 'with limit param' do
let(:params) { { limit: 1 } }
it 'returns only the requested number of accounts' do
subject
expect(body_as_json.size).to eq 1
end
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
describe 'DELETE /api/v1/suggestions/:id' do
subject do
delete "/api/v1/suggestions/#{jeff.id}", headers: headers
end
let(:suggestions_source) { instance_double(AccountSuggestions::PastInteractionsSource, remove: nil) }
let(:bob) { Fabricate(:account) }
let(:jeff) { Fabricate(:account) }
before do
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
allow(AccountSuggestions::PastInteractionsSource).to receive(:new).and_return(suggestions_source)
end
it_behaves_like 'forbidden for wrong scope', 'write'
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'removes the specified suggestion' do
subject
expect(suggestions_source).to have_received(:remove).with(user.account, jeff.id.to_s).once
expect(suggestions_source).to_not have_received(:remove).with(user.account, bob.id.to_s)
end
context 'without an authorization header' do
let(:headers) { {} }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
end

View File

@ -0,0 +1,169 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Tags' do
let(:user) { Fabricate(:user) }
let(:scopes) { 'write:follows' }
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }
describe 'GET /api/v1/tags/:id' do
subject do
get "/api/v1/tags/#{name}"
end
context 'when the tag exists' do
let!(:tag) { Fabricate(:tag) }
let(:name) { tag.name }
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'returns the tag' do
subject
expect(body_as_json[:name]).to eq(name)
end
end
context 'when the tag does not exist' do
let(:name) { 'hoge' }
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
end
context 'when the tag name is invalid' do
let(:name) { 'tag-name' }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
end
describe 'POST /api/v1/tags/:id/follow' do
subject do
post "/api/v1/tags/#{name}/follow", headers: headers
end
let!(:tag) { Fabricate(:tag) }
let(:name) { tag.name }
it_behaves_like 'forbidden for wrong scope', 'read read:follows'
context 'when the tag exists' do
it 'returns http success' do
subject
expect(response).to have_http_status(:success)
end
it 'creates follow' do
subject
expect(TagFollow.where(tag: tag, account: user.account)).to exist
end
end
context 'when the tag does not exist' do
let(:name) { 'hoge' }
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'creates a new tag with the specified name' do
subject
expect(Tag.where(name: name)).to exist
end
it 'creates follow' do
subject
expect(TagFollow.where(tag: Tag.find_by(name: name), account: user.account)).to exist
end
end
context 'when the tag name is invalid' do
let(:name) { 'tag-name' }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'when the Authorization header is missing' do
let(:headers) { {} }
let(:name) { 'unauthorized' }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
describe 'POST #unfollow' do
subject do
post "/api/v1/tags/#{name}/unfollow", headers: headers
end
let(:name) { tag.name }
let!(:tag) { Fabricate(:tag, name: 'foo') }
before do
Fabricate(:tag_follow, account: user.account, tag: tag)
end
it_behaves_like 'forbidden for wrong scope', 'read read:follows'
it 'returns http success' do
subject
expect(response).to have_http_status(200)
end
it 'removes the follow' do
subject
expect(TagFollow.where(tag: tag, account: user.account)).to_not exist
end
context 'when the tag name is invalid' do
let(:name) { 'tag-name' }
it 'returns http not found' do
subject
expect(response).to have_http_status(404)
end
end
context 'when the Authorization header is missing' do
let(:headers) { {} }
let(:name) { 'unauthorized' }
it 'returns http unauthorized' do
subject
expect(response).to have_http_status(401)
end
end
end
end

View File

@ -53,7 +53,7 @@ describe AccountSearchService, type: :service do
context 'when there is a domain but no exact match' do context 'when there is a domain but no exact match' do
it 'follows the remote account when resolve is true' do it 'follows the remote account when resolve is true' do
service = double(call: nil) service = instance_double(ResolveAccountService, call: nil)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true) results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true)
@ -61,7 +61,7 @@ describe AccountSearchService, type: :service do
end end
it 'does not follow the remote account when resolve is false' do it 'does not follow the remote account when resolve is false' do
service = double(call: nil) service = instance_double(ResolveAccountService, call: nil)
allow(ResolveAccountService).to receive(:new).and_return(service) allow(ResolveAccountService).to receive(:new).and_return(service)
results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false) results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false)

View File

@ -30,7 +30,7 @@ RSpec.describe BackupService, type: :service do
it 'stores them as expected' do it 'stores them as expected' do
service_call service_call
json = Oj.load(read_zip_file(backup, 'actor.json')) json = export_json(:actor)
avatar_path = json.dig('icon', 'url') avatar_path = json.dig('icon', 'url')
header_path = json.dig('image', 'url') header_path = json.dig('image', 'url')
@ -42,47 +42,60 @@ RSpec.describe BackupService, type: :service do
end end
end end
it 'marks the backup as processed' do it 'marks the backup as processed and exports files' do
expect { service_call }.to change(backup, :processed).from(false).to(true) expect { service_call }.to process_backup
expect_outbox_export
expect_likes_export
expect_bookmarks_export
end end
it 'exports outbox.json as expected' do def process_backup
service_call change(backup, :processed).from(false).to(true)
end
json = Oj.load(read_zip_file(backup, 'outbox.json')) def expect_outbox_export
json = export_json(:outbox)
aggregate_failures do
expect(json['@context']).to_not be_nil expect(json['@context']).to_not be_nil
expect(json['type']).to eq 'OrderedCollection' expect(json['type']).to eq 'OrderedCollection'
expect(json['totalItems']).to eq 2 expect(json['totalItems']).to eq 2
expect(json['orderedItems'][0]['@context']).to be_nil expect(json['orderedItems'][0]['@context']).to be_nil
expect(json['orderedItems'][0]).to include({ expect(json['orderedItems'][0]).to include_create_item(status)
'type' => 'Create', expect(json['orderedItems'][1]).to include_create_item(private_status)
'object' => include({ end
'id' => ActivityPub::TagManager.instance.uri_for(status),
'content' => '<p>Hello</p>',
}),
})
expect(json['orderedItems'][1]).to include({
'type' => 'Create',
'object' => include({
'id' => ActivityPub::TagManager.instance.uri_for(private_status),
'content' => '<p>secret</p>',
}),
})
end end
it 'exports likes.json as expected' do def expect_likes_export
service_call json = export_json(:likes)
json = Oj.load(read_zip_file(backup, 'likes.json')) aggregate_failures do
expect(json['type']).to eq 'OrderedCollection' expect(json['type']).to eq 'OrderedCollection'
expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)] expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
end end
end
it 'exports bookmarks.json as expected' do def expect_bookmarks_export
service_call json = export_json(:bookmarks)
json = Oj.load(read_zip_file(backup, 'bookmarks.json')) aggregate_failures do
expect(json['type']).to eq 'OrderedCollection' expect(json['type']).to eq 'OrderedCollection'
expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)] expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
end end
end
def export_json(type)
Oj.load(read_zip_file(backup, "#{type}.json"))
end
def include_create_item(status)
include({
'type' => 'Create',
'object' => include({
'id' => ActivityPub::TagManager.instance.uri_for(status),
'content' => "<p>#{status.text}</p>",
}),
})
end
end end

View File

@ -6,7 +6,7 @@ RSpec.describe BootstrapTimelineService, type: :service do
subject { described_class.new } subject { described_class.new }
context 'when the new user has registered from an invite' do context 'when the new user has registered from an invite' do
let(:service) { double } let(:service) { instance_double(FollowService) }
let(:autofollow) { false } let(:autofollow) { false }
let(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) } let(:inviter) { Fabricate(:user, confirmed_at: 2.days.ago) }
let(:invite) { Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now, autofollow: autofollow) } let(:invite) { Fabricate(:invite, user: inviter, max_uses: nil, expires_at: 1.hour.from_now, autofollow: autofollow) }

View File

@ -47,7 +47,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the listed users once the workers have run' do it 'requests to follow all the listed users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -95,7 +95,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do it 'requests to follow all the expected users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -133,7 +133,7 @@ RSpec.describe BulkImportService do
it 'blocks all the listed users once the workers have run' do it 'blocks all the listed users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -177,7 +177,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do it 'requests to follow all the expected users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -215,7 +215,7 @@ RSpec.describe BulkImportService do
it 'mutes all the listed users once the workers have run' do it 'mutes all the listed users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -263,7 +263,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do it 'requests to follow all the expected users once the workers have run' do
subject.call(import) subject.call(import)
resolve_account_service_double = double resolve_account_service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service_double)
allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('user@foo.bar', any_args) { Fabricate(:account, username: 'user', domain: 'foo.bar', protocol: :activitypub) }
allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) } allow(resolve_account_service_double).to receive(:call).with('unknown@unknown.bar', any_args) { Fabricate(:account, username: 'unknown', domain: 'unknown.bar', protocol: :activitypub) }
@ -360,7 +360,7 @@ RSpec.describe BulkImportService do
it 'updates the bookmarks as expected once the workers have run' do it 'updates the bookmarks as expected once the workers have run' do
subject.call(import) subject.call(import)
service_double = double service_double = instance_double(ActivityPub::FetchRemoteStatusService)
allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
@ -403,7 +403,7 @@ RSpec.describe BulkImportService do
it 'updates the bookmarks as expected once the workers have run' do it 'updates the bookmarks as expected once the workers have run' do
subject.call(import) subject.call(import)
service_double = double service_double = instance_double(ActivityPub::FetchRemoteStatusService)
allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service_double)
allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') } allow(service_double).to receive(:call).with('https://domain.unknown/foo') { Fabricate(:status, uri: 'https://domain.unknown/foo') }
allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) } allow(service_double).to receive(:call).with('https://domain.unknown/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }

View File

@ -24,7 +24,7 @@ RSpec.describe FetchResourceService, type: :service do
context 'when OpenSSL::SSL::SSLError is raised' do context 'when OpenSSL::SSL::SSLError is raised' do
before do before do
request = double request = instance_double(Request)
allow(Request).to receive(:new).and_return(request) allow(Request).to receive(:new).and_return(request)
allow(request).to receive(:add_headers) allow(request).to receive(:add_headers)
allow(request).to receive(:on_behalf_of) allow(request).to receive(:on_behalf_of)
@ -36,7 +36,7 @@ RSpec.describe FetchResourceService, type: :service do
context 'when HTTP::ConnectionError is raised' do context 'when HTTP::ConnectionError is raised' do
before do before do
request = double request = instance_double(Request)
allow(Request).to receive(:new).and_return(request) allow(Request).to receive(:new).and_return(request)
allow(request).to receive(:add_headers) allow(request).to receive(:add_headers)
allow(request).to receive(:on_behalf_of) allow(request).to receive(:on_behalf_of)

View File

@ -219,7 +219,7 @@ RSpec.describe ImportService, type: :service do
end end
before do before do
service = double service = instance_double(ActivityPub::FetchRemoteStatusService)
allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service) allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do
Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1') Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1')

View File

@ -132,7 +132,7 @@ RSpec.describe PostStatusService, type: :service do
end end
it 'processes mentions' do it 'processes mentions' do
mention_service = double(:process_mentions_service) mention_service = instance_double(ProcessMentionsService)
allow(mention_service).to receive(:call) allow(mention_service).to receive(:call)
allow(ProcessMentionsService).to receive(:new).and_return(mention_service) allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
account = Fabricate(:account) account = Fabricate(:account)
@ -163,7 +163,7 @@ RSpec.describe PostStatusService, type: :service do
end end
it 'processes hashtags' do it 'processes hashtags' do
hashtags_service = double(:process_hashtags_service) hashtags_service = instance_double(ProcessHashtagsService)
allow(hashtags_service).to receive(:call) allow(hashtags_service).to receive(:call)
allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service) allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
account = Fabricate(:account) account = Fabricate(:account)

View File

@ -9,7 +9,7 @@ describe ResolveURLService, type: :service do
it 'returns nil when there is no resource url' do it 'returns nil when there is no resource url' do
url = 'http://example.com/missing-resource' url = 'http://example.com/missing-resource'
known_account = Fabricate(:account, uri: url) known_account = Fabricate(:account, uri: url)
service = double service = instance_double(FetchResourceService)
allow(FetchResourceService).to receive(:new).and_return service allow(FetchResourceService).to receive(:new).and_return service
allow(service).to receive(:response_code).and_return(404) allow(service).to receive(:response_code).and_return(404)
@ -21,7 +21,7 @@ describe ResolveURLService, type: :service do
it 'returns known account on temporary error' do it 'returns known account on temporary error' do
url = 'http://example.com/missing-resource' url = 'http://example.com/missing-resource'
known_account = Fabricate(:account, uri: url) known_account = Fabricate(:account, uri: url)
service = double service = instance_double(FetchResourceService)
allow(FetchResourceService).to receive(:new).and_return service allow(FetchResourceService).to receive(:new).and_return service
allow(service).to receive(:response_code).and_return(500) allow(service).to receive(:response_code).and_return(500)

View File

@ -25,7 +25,7 @@ describe SearchService, type: :service do
context 'when it does not find anything' do context 'when it does not find anything' do
it 'returns the empty results' do it 'returns the empty results' do
service = double(call: nil) service = instance_double(ResolveURLService, call: nil)
allow(ResolveURLService).to receive(:new).and_return(service) allow(ResolveURLService).to receive(:new).and_return(service)
results = subject.call(@query, nil, 10, resolve: true) results = subject.call(@query, nil, 10, resolve: true)
@ -37,7 +37,7 @@ describe SearchService, type: :service do
context 'when it finds an account' do context 'when it finds an account' do
it 'includes the account in the results' do it 'includes the account in the results' do
account = Account.new account = Account.new
service = double(call: account) service = instance_double(ResolveURLService, call: account)
allow(ResolveURLService).to receive(:new).and_return(service) allow(ResolveURLService).to receive(:new).and_return(service)
results = subject.call(@query, nil, 10, resolve: true) results = subject.call(@query, nil, 10, resolve: true)
@ -49,7 +49,7 @@ describe SearchService, type: :service do
context 'when it finds a status' do context 'when it finds a status' do
it 'includes the status in the results' do it 'includes the status in the results' do
status = Status.new status = Status.new
service = double(call: status) service = instance_double(ResolveURLService, call: status)
allow(ResolveURLService).to receive(:new).and_return(service) allow(ResolveURLService).to receive(:new).and_return(service)
results = subject.call(@query, nil, 10, resolve: true) results = subject.call(@query, nil, 10, resolve: true)
@ -64,7 +64,7 @@ describe SearchService, type: :service do
it 'includes the account in the results' do it 'includes the account in the results' do
query = 'username' query = 'username'
account = Account.new account = Account.new
service = double(call: [account]) service = instance_double(AccountSearchService, call: [account])
allow(AccountSearchService).to receive(:new).and_return(service) allow(AccountSearchService).to receive(:new).and_return(service)
results = subject.call(query, nil, 10) results = subject.call(query, nil, 10)

View File

@ -63,7 +63,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
describe 'unsuspending a remote account' do describe 'unsuspending a remote account' do
include_examples 'with common context' do include_examples 'with common context' do
let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) } let!(:account) { Fabricate(:account, domain: 'bob.com', uri: 'https://bob.com', inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
let!(:resolve_account_service) { double } let!(:resolve_account_service) { instance_double(ResolveAccountService) }
before do before do
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service) allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service)

View File

@ -6,8 +6,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
describe '#validate' do describe '#validate' do
subject { described_class.new.validate(user); errors } subject { described_class.new.validate(user); errors }
let(:user) { double(email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) } let(:user) { instance_double(User, email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) }
let(:errors) { double(add: nil) } let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
before do before do
allow(user).to receive(:valid_invitation?).and_return(false) allow(user).to receive(:valid_invitation?).and_return(false)

View File

@ -11,8 +11,8 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do
described_class.new.validate(status) described_class.new.validate(status)
end end
let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) } let(:status) { instance_double(Status, errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
let(:errors) { double(add: nil) } let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
context 'with a remote reblog' do context 'with a remote reblog' do
let(:local) { false } let(:local) { false }

View File

@ -4,7 +4,7 @@ require 'rails_helper'
describe EmailMxValidator do describe EmailMxValidator do
describe '#validate' do describe '#validate' do
let(:user) { double(email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) } let(:user) { instance_double(User, email: 'foo@example.com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil)) }
context 'with an e-mail domain that is explicitly allowed' do context 'with an e-mail domain that is explicitly allowed' do
around do |block| around do |block|
@ -15,7 +15,7 @@ describe EmailMxValidator do
end end
it 'does not add errors if there are no DNS records' do it 'does not add errors if there are no DNS records' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
@ -29,7 +29,7 @@ describe EmailMxValidator do
end end
it 'adds no error if there are DNS records for the e-mail domain' do it 'adds no error if there are DNS records for the e-mail domain' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')])
@ -46,19 +46,19 @@ describe EmailMxValidator do
allow(TagManager).to receive(:instance).and_return(double) allow(TagManager).to receive(:instance).and_return(double)
allow(double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError) allow(double).to receive(:normalize_domain).with('example.com').and_raise(Addressable::URI::InvalidURIError)
user = double(email: 'foo@example.com', errors: double(add: nil)) user = instance_double(User, email: 'foo@example.com', errors: instance_double(ActiveModel::Errors, add: nil))
subject.validate(user) subject.validate(user)
expect(user.errors).to have_received(:add) expect(user.errors).to have_received(:add)
end end
it 'adds an error if the domain email portion is blank' do it 'adds an error if the domain email portion is blank' do
user = double(email: 'foo@', errors: double(add: nil)) user = instance_double(User, email: 'foo@', errors: instance_double(ActiveModel::Errors, add: nil))
subject.validate(user) subject.validate(user)
expect(user.errors).to have_received(:add) expect(user.errors).to have_received(:add)
end end
it 'adds an error if the email domain name contains empty labels' do it 'adds an error if the email domain name contains empty labels' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')]) allow(resolver).to receive(:getresources).with('example..com', Resolv::DNS::Resource::IN::A).and_return([Resolv::DNS::Resource::IN::A.new('192.0.2.42')])
@ -66,13 +66,13 @@ describe EmailMxValidator do
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)
user = double(email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: double(add: nil)) user = instance_double(User, email: 'foo@example..com', sign_up_ip: '1.2.3.4', errors: instance_double(ActiveModel::Errors, add: nil))
subject.validate(user) subject.validate(user)
expect(user.errors).to have_received(:add) expect(user.errors).to have_received(:add)
end end
it 'adds an error if there are no DNS records for the e-mail domain' do it 'adds an error if there are no DNS records for the e-mail domain' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
@ -85,9 +85,11 @@ describe EmailMxValidator do
end end
it 'adds an error if a MX record does not lead to an IP' do it 'adds an error if a MX record does not lead to an IP' do
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources)
.with('example.com', Resolv::DNS::Resource::IN::MX)
.and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([])
@ -101,13 +103,15 @@ describe EmailMxValidator do
it 'adds an error if the MX record is blacklisted' do it 'adds an error if the MX record is blacklisted' do
EmailDomainBlock.create!(domain: 'mail.example.com') EmailDomainBlock.create!(domain: 'mail.example.com')
resolver = double resolver = instance_double(Resolv::DNS)
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::MX).and_return([double(exchange: 'mail.example.com')]) allow(resolver).to receive(:getresources)
.with('example.com', Resolv::DNS::Resource::IN::MX)
.and_return([instance_double(Resolv::DNS::Resource::MX, exchange: 'mail.example.com')])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::A).and_return([])
allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([]) allow(resolver).to receive(:getresources).with('example.com', Resolv::DNS::Resource::IN::AAAA).and_return([])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([double(address: '2.3.4.5')]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::A).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: '2.3.4.5')])
allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([double(address: 'fd00::2')]) allow(resolver).to receive(:getresources).with('mail.example.com', Resolv::DNS::Resource::IN::AAAA).and_return([instance_double(Resolv::DNS::Resource::IN::A, address: 'fd00::2')])
allow(resolver).to receive(:timeouts=).and_return(nil) allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver) allow(Resolv::DNS).to receive(:open).and_yield(resolver)

View File

@ -12,9 +12,9 @@ RSpec.describe FollowLimitValidator, type: :validator do
described_class.new.validate(follow) described_class.new.validate(follow)
end end
let(:follow) { double(account: account, errors: errors) } let(:follow) { instance_double(Follow, account: account, errors: errors) }
let(:errors) { double(add: nil) } let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:account) { double(nil?: _nil, local?: local, following_count: 0, followers_count: 0) } let(:account) { instance_double(Account, nil?: _nil, local?: local, following_count: 0, followers_count: 0) }
let(:_nil) { true } let(:_nil) { true }
let(:local) { false } let(:local) { false }

View File

@ -8,7 +8,7 @@ describe NoteLengthValidator do
describe '#validate' do describe '#validate' do
it 'adds an error when text is over 500 characters' do it 'adds an error when text is over 500 characters' do
text = 'a' * 520 text = 'a' * 520
account = double(note: text, errors: double(add: nil)) account = instance_double(Account, note: text, errors: activemodel_errors)
subject.validate_each(account, 'note', text) subject.validate_each(account, 'note', text)
expect(account.errors).to have_received(:add) expect(account.errors).to have_received(:add)
@ -16,7 +16,7 @@ describe NoteLengthValidator do
it 'counts URLs as 23 characters flat' do it 'counts URLs as 23 characters flat' do
text = ('a' * 476) + " http://#{'b' * 30}.com/example" text = ('a' * 476) + " http://#{'b' * 30}.com/example"
account = double(note: text, errors: double(add: nil)) account = instance_double(Account, note: text, errors: activemodel_errors)
subject.validate_each(account, 'note', text) subject.validate_each(account, 'note', text)
expect(account.errors).to_not have_received(:add) expect(account.errors).to_not have_received(:add)
@ -24,10 +24,16 @@ describe NoteLengthValidator do
it 'does not count non-autolinkable URLs as 23 characters flat' do it 'does not count non-autolinkable URLs as 23 characters flat' do
text = ('a' * 476) + "http://#{'b' * 30}.com/example" text = ('a' * 476) + "http://#{'b' * 30}.com/example"
account = double(note: text, errors: double(add: nil)) account = instance_double(Account, note: text, errors: activemodel_errors)
subject.validate_each(account, 'note', text) subject.validate_each(account, 'note', text)
expect(account.errors).to have_received(:add) expect(account.errors).to have_received(:add)
end end
private
def activemodel_errors
instance_double(ActiveModel::Errors, add: nil)
end
end end
end end

View File

@ -9,8 +9,8 @@ RSpec.describe PollValidator, type: :validator do
end end
let(:validator) { described_class.new } let(:validator) { described_class.new }
let(:poll) { double(options: options, expires_at: expires_at, errors: errors) } let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) }
let(:errors) { double(add: nil) } let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:options) { %w(foo bar) } let(:options) { %w(foo bar) }
let(:expires_at) { 1.day.from_now } let(:expires_at) { 1.day.from_now }

Some files were not shown because too many files have changed in this diff Show More