M .rubocop_todo.yml => .rubocop_todo.yml +0 -41
@@ 318,7 318,6 @@ RSpec/LetSetup:
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/filters_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/filters/keywords_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/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).
Rails/ApplicationController:
Exclude:
@@ 759,7 719,6 @@ Rails/WhereExists:
- 'app/workers/move_worker.rb'
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
- 'lib/tasks/tests.rake'
- - 'spec/controllers/api/v1/tags_controller_spec.rb'
- 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb'
M Gemfile.lock => Gemfile.lock +1 -1
@@ 106,7 106,7 @@ GEM
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
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-kms (~> 1)
aws-sigv4 (~> 1.4)
M app/controllers/admin/webhooks_controller.rb => app/controllers/admin/webhooks_controller.rb +4 -1
@@ 28,6 28,7 @@ module Admin
authorize :webhook, :create?
@webhook = Webhook.new(resource_params)
+ @webhook.current_account = current_account
if @webhook.save
redirect_to admin_webhook_path(@webhook)
@@ 39,10 40,12 @@ module Admin
def update
authorize @webhook, :update?
+ @webhook.current_account = current_account
+
if @webhook.update(resource_params)
redirect_to admin_webhook_path(@webhook)
else
- render :show
+ render :edit
end
end
M app/controllers/api/v1/conversations_controller.rb => app/controllers/api/v1/conversations_controller.rb +5 -0
@@ 19,6 19,11 @@ class Api::V1::ConversationsController < Api::BaseController
render json: @conversation, serializer: REST::ConversationSerializer
end
+ def unread
+ @conversation.update!(unread: true)
+ render json: @conversation, serializer: REST::ConversationSerializer
+ end
+
def destroy
@conversation.destroy!
render_empty
M app/controllers/api/v1/statuses/histories_controller.rb => app/controllers/api/v1/statuses/histories_controller.rb +5 -1
@@ 8,11 8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
def show
cache_if_unauthenticated!
- render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
+ render json: status_edits, each_serializer: REST::StatusEditSerializer
end
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
@status = Status.find(params[:status_id])
authorize @status, :show?
M app/controllers/api/v2/admin/accounts_controller.rb => app/controllers/api/v2/admin/accounts_controller.rb +8 -0
@@ 18,6 18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
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
AccountFilter.new(translated_filter_params).results
end
M app/helpers/settings_helper.rb => app/helpers/settings_helper.rb +0 -9
@@ 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')], ' ')
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
A app/javascript/images/friends-cropped.png => app/javascript/images/friends-cropped.png +0 -0
M app/javascript/mastodon/components/account.jsx => app/javascript/mastodon/components/account.jsx +12 -2
@@ 1,6 1,6 @@
import PropTypes from 'prop-types';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
@@ 49,6 49,7 @@ class Account extends ImmutablePureComponent {
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
+ withBio: PropTypes.bool,
};
static defaultProps = {
@@ 80,7 81,7 @@ class Account extends ImmutablePureComponent {
};
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) {
return <EmptyAccount size={size} minimal={minimal} />;
@@ 171,6 172,15 @@ class Account extends ImmutablePureComponent {
</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>
);
}
R app/javascript/mastodon/components/autosuggest_hashtag.jsx => app/javascript/mastodon/components/autosuggest_hashtag.tsx +34 -36
@@ 1,44 1,42 @@
-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,
+interface Props {
+ tag: {
+ name: string;
+ url?: string;
+ history?: Array<{
+ uses: number;
+ accounts: string;
+ day: string;
+ }>;
+ following?: boolean;
+ type: 'hashtag';
};
+}
- render() {
- const { tag } = this.props;
- const weeklyUses = tag.history && (
- <ShortNumber
- value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
- />
- );
+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>
- )}
+ 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>
+ );
+};
M app/javascript/mastodon/components/autosuggest_input.jsx => app/javascript/mastodon/components/autosuggest_input.jsx +1 -1
@@ 8,7 8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
+import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;
M app/javascript/mastodon/components/autosuggest_textarea.jsx => app/javascript/mastodon/components/autosuggest_textarea.jsx +1 -1
@@ 10,7 10,7 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
-import AutosuggestHashtag from './autosuggest_hashtag';
+import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;
M app/javascript/mastodon/components/verified_badge.tsx => app/javascript/mastodon/components/verified_badge.tsx +17 -1
@@ 1,11 1,27 @@
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 {
link: string;
}
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'>
<Icon id='check' className='verified-badge__mark' />
- <span dangerouslySetInnerHTML={{ __html: link }} />
+ <span dangerouslySetInnerHTML={stripRelMe(link)} />
</span>
);
M app/javascript/mastodon/features/bookmarked_statuses/index.jsx => app/javascript/mastodon/features/bookmarked_statuses/index.jsx +2 -1
@@ 15,13 15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column';
+import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
+ statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});
M => +1 -4
@@ 140,11 140,8 @@ class CommunityTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
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}
scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`}
M app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx => app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.jsx +1 -1
@@ 389,7 389,7 @@ class EmojiPickerDropdown extends PureComponent {
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
- src={`${assetHost}/emoji/1f602.svg`}
+ src={`${assetHost}/emoji/1f642.svg`}
/>}
</div>
M app/javascript/mastodon/features/explore/links.jsx => app/javascript/mastodon/features/explore/links.jsx +1 -1
@@ 35,7 35,7 @@ class Links extends PureComponent {
const banner = (
<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>
);
M app/javascript/mastodon/features/explore/statuses.jsx => app/javascript/mastodon/features/explore/statuses.jsx +3 -2
@@ 11,9 11,10 @@ import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import StatusList from 'mastodon/components/status_list';
+import { getStatusList } from 'mastodon/selectors';
const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'trending', 'items']),
+ statusIds: getStatusList(state, 'trending'),
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
});
@@ 46,7 47,7 @@ class Statuses extends PureComponent {
return (
<>
<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>
<StatusList
M app/javascript/mastodon/features/explore/tags.jsx => app/javascript/mastodon/features/explore/tags.jsx +1 -1
@@ 34,7 34,7 @@ class Tags extends PureComponent {
const banner = (
<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>
);
M app/javascript/mastodon/features/favourited_statuses/index.jsx => app/javascript/mastodon/features/favourited_statuses/index.jsx +2 -1
@@ 15,13 15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column';
+import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
});
const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+ statusIds: getStatusList(state, 'favourites'),
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
});
A app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx => app/javascript/mastodon/features/home_timeline/components/explore_prompt.jsx +24 -0
@@ 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>
+);<
\ No newline at end of file
M app/javascript/mastodon/features/home_timeline/index.jsx => app/javascript/mastodon/features/home_timeline/index.jsx +35 -4
@@ 5,14 5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
-import { Link } from 'react-router-dom';
+import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
+import { me } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';
@@ 20,6 22,7 @@ import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';
+import { ExplorePrompt } from './components/explore_prompt';
import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({
@@ 28,12 31,33 @@ const messages = defineMessages({
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 => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
+ tooSlow: homeTooSlow(state),
});
class HomeTimeline extends PureComponent {
@@ 52,6 76,7 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
+ tooSlow: PropTypes.bool,
};
handlePin = () => {
@@ 121,11 146,11 @@ class HomeTimeline extends PureComponent {
};
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 { signedIn } = this.context.identity;
- let announcementsButton = null;
+ let announcementsButton, banner;
if (hasAnnouncements) {
announcementsButton = (
@@ 141,6 166,10 @@ class HomeTimeline extends PureComponent {
);
}
+ if (tooSlow) {
+ banner = <ExplorePrompt />;
+ }
+
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
@@ 160,11 189,13 @@ class HomeTimeline extends PureComponent {
{signedIn ? (
<StatusListContainer
+ prepend={banner}
+ alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
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}
/>
) : <NotSignedInIndicator />}
M app/javascript/mastodon/features/onboarding/components/step.jsx => app/javascript/mastodon/features/onboarding/components/step.jsx +5 -5
@@ 3,6 3,8 @@ import PropTypes from 'prop-types';
import { Check } from 'mastodon/components/check';
import { Icon } from 'mastodon/components/icon';
+import ArrowSmallRight from './arrow_small_right';
+
const Step = ({ label, description, icon, completed, onClick, href }) => {
const content = (
<>
@@ 15,11 17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {
<p>{description}</p>
</div>
- {completed && (
- <div className='onboarding__steps__item__progress'>
- <Check />
- </div>
- )}
+ <div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
+ {completed ? <Check /> : <ArrowSmallRight />}
+ </div>
</>
);
M app/javascript/mastodon/features/onboarding/follows.jsx => app/javascript/mastodon/features/onboarding/follows.jsx +6 -18
@@ 12,20 12,11 @@ import Column from 'mastodon/components/column';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { EmptyAccount } from 'mastodon/components/empty_account';
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 = () => {
- const getAccount = makeGetAccount();
-
- return state => ({
- account: getAccount(state, me),
- suggestions: state.getIn(['suggestions', 'items']),
- isLoading: state.getIn(['suggestions', 'isLoading']),
- });
-};
+const mapStateToProps = state => ({
+ suggestions: state.getIn(['suggestions', 'items']),
+ isLoading: state.getIn(['suggestions', 'isLoading']),
+});
class Follows extends PureComponent {
@@ 33,7 24,6 @@ class Follows extends PureComponent {
onBack: PropTypes.func,
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
- account: ImmutablePropTypes.map,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
};
@@ 49,7 39,7 @@ class Follows extends PureComponent {
}
render () {
- const { onBack, isLoading, suggestions, account, multiColumn } = this.props;
+ const { onBack, isLoading, suggestions, multiColumn } = this.props;
let loadedContent;
@@ 58,7 48,7 @@ class Follows extends PureComponent {
} 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>;
} 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 (
@@ 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>
</div>
- <ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
-
<div className='follow-recommendations'>
{loadedContent}
</div>
M app/javascript/mastodon/features/onboarding/index.jsx => app/javascript/mastodon/features/onboarding/index.jsx +8 -6
@@ 19,6 19,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
import Column from 'mastodon/features/ui/components/column';
import { me } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
+import { assetHost } from 'mastodon/utils/config';
import ArrowSmallRight from './components/arrow_small_right';
import Step from './components/step';
@@ 122,21 123,22 @@ class Onboarding extends ImmutablePureComponent {
<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.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!' />} />
</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'>
<Link to='/explore' className='onboarding__link'>
+ <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
- <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
- </div>
- <div className='onboarding__footer'>
- <button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button>
+ <Link to='/home' className='onboarding__link'>
+ <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
+ <ArrowSmallRight />
+ </Link>
</div>
</div>
M app/javascript/mastodon/features/onboarding/share.jsx => app/javascript/mastodon/features/onboarding/share.jsx +2 -2
@@ 177,13 177,13 @@ class Share extends PureComponent {
<div className='onboarding__links'>
<Link to='/home' className='onboarding__link'>
+ <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
- <FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
</Link>
<Link to='/explore' className='onboarding__link'>
+ <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
- <FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
</div>
M app/javascript/mastodon/features/pinned_statuses/index.jsx => app/javascript/mastodon/features/pinned_statuses/index.jsx +3 -1
@@ 8,6 8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
+import { getStatusList } from 'mastodon/selectors';
+
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
@@ 18,7 20,7 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
- statusIds: state.getIn(['status_lists', 'pins', 'items']),
+ statusIds: getStatusList(state, 'pins'),
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
});
M app/javascript/mastodon/features/public_timeline/index.jsx => app/javascript/mastodon/features/public_timeline/index.jsx +1 -4
@@ 142,11 142,8 @@ class PublicTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
+ 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' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
M => +10 -0
@@ 8,6 8,7 @@ import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { fetchServer } from 'mastodon/actions/server';
import { Avatar } from 'mastodon/components/avatar';
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
import { registrationsOpen, me } from 'mastodon/initial_state';
@@ 28,6 29,9 @@ const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
},
dispatchServer() {
dispatch(fetchServer());
}
});
class Header extends PureComponent {
@@ 40,8 44,14 @@ class Header extends PureComponent {
openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object,
signupUrl: PropTypes.string.isRequired,
dispatchServer: PropTypes.func
};
componentDidMount () {
const { dispatchServer } = this.props;
dispatchServer();
}
render () {
const { signedIn } = this.context.identity;
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +22 -19
@@ 52,6 52,7 @@
"account.mute_notifications_short": "Mute notifications",
"account.mute_short": "Mute",
"account.muted": "Muted",
+ "account.no_bio": "No description provided.",
"account.open_original_page": "Open original page",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",
@@ 197,9 198,9 @@
"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.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_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
- "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_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 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 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.",
"embed.instructions": "Embed this post on your website by copying the code below.",
"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.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.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
- "empty_column.home.suggestions": "See some suggestions",
+ "empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
"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.mutes": "You haven't muted any users yet.",
@@ 292,9 292,13 @@
"hashtag.column_settings.tag_toggle": "Include additional tags for this column",
"hashtag.follow": "Follow 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.show_reblogs": "Show boosts",
"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.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.",
@@ 449,28 453,27 @@
"notifications_permission_banner.title": "Never miss a thing",
"onboarding.action.back": "Take me back",
"onboarding.actions.back": "Take me back",
- "onboarding.actions.close": "Don't show this screen again",
- "onboarding.actions.go_to_explore": "See what's trending",
- "onboarding.actions.go_to_home": "Go to your home feed",
+ "onboarding.actions.go_to_explore": "Take me to trending",
+ "onboarding.actions.go_to_home": "Take me to my home feed",
"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.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.title": "Popular on Mastodon",
+ "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": "Personalize your home feed",
"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.next_steps": "Possible next steps:",
"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.skip": "Want to skip right ahead?",
+ "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": "Don't need help getting started?",
"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.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
- "onboarding.steps.publish_status.body": "Say hello to the world.",
+ "onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
+ "onboarding.steps.follow_people.title": "Personalize your home feed",
+ "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.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
- "onboarding.steps.setup_profile.title": "Customize your profile",
- "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.setup_profile.body": "Boost your interactions by having a comprehensive 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.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.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!",
M app/javascript/mastodon/selectors/index.js => app/javascript/mastodon/selectors/index.js +4 -0
@@ 137,3 137,7 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
});
+
+export const getStatusList = createSelector([
+ (state, type) => state.getIn(['status_lists', type, 'items']),
+], (items) => items.toList());
M app/javascript/styles/mastodon-light/diff.scss => app/javascript/styles/mastodon-light/diff.scss +0 -5
@@ 653,11 653,6 @@ html {
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,
.reply-indicator__content {
a {
M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +112 -23
@@ 1514,12 1514,37 @@ body > [data-popper-placement] {
}
&__note {
+ font-size: 14px;
+ font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
- -webkit-line-clamp: 2;
+ -webkit-line-clamp: 1;
-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 {
display: flex;
align-items: center;
+ justify-content: space-between;
gap: 10px;
color: $highlight-text-color;
background: lighten($ui-base-color, 4%);
border-radius: 8px;
- padding: 10px;
+ padding: 10px 15px;
box-sizing: border-box;
- font-size: 17px;
+ font-size: 14px;
+ font-weight: 500;
height: 56px;
text-decoration: none;
@@ 2685,6 2712,7 @@ $ui-header-height: 55px;
align-items: center;
gap: 10px;
padding: 10px;
+ padding-inline-end: 15px;
margin-bottom: 2px;
text-decoration: none;
text-align: start;
@@ 2697,14 2725,14 @@ $ui-header-height: 55px;
&__icon {
flex: 0 0 auto;
- background: $ui-base-color;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
- color: $dark-text-color;
+ color: $highlight-text-color;
+ font-size: 1.2rem;
@media screen and (width >= 600px) {
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 {
flex: 1 1 auto;
- line-height: 18px;
+ line-height: 20px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
h6 {
- color: $primary-text-color;
- font-weight: 700;
+ color: $highlight-text-color;
+ font-weight: 500;
+ font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
@@ 8695,27 8740,71 @@ noscript {
}
.dismissable-banner {
- background: $ui-base-color;
- border-bottom: 1px solid lighten($ui-base-color, 8%);
- display: flex;
- align-items: center;
- gap: 30px;
+ position: relative;
+ margin: 10px;
+ margin-bottom: 5px;
+ border-radius: 8px;
+ 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 {
flex: 1 1 auto;
- padding: 20px 15px;
- cursor: default;
- font-size: 14px;
- line-height: 18px;
+ padding: 15px;
+ font-size: 15px;
+ line-height: 22px;
+ font-weight: 500;
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 {
- padding: 15px;
- flex: 0 0 auto;
- display: flex;
- align-items: center;
- justify-content: center;
+ position: absolute;
+ inset-inline-end: 0;
+ top: 0;
+ padding: 10px;
+
+ .icon-button {
+ color: $highlight-text-color;
+ }
}
}
M app/lib/request_pool.rb => app/lib/request_pool.rb +3 -2
@@ 28,8 28,9 @@ class RequestPool
end
MAX_IDLE_TIME = 30
- WAIT_TIMEOUT = 5
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
+ REAPER_FREQUENCY = 30
+ WAIT_TIMEOUT = 5
class Connection
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
@@ 98,7 99,7 @@ class RequestPool
def initialize
@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
end
M app/lib/text_formatter.rb => app/lib/text_formatter.rb +2 -2
@@ 79,7 79,7 @@ class TextFormatter
cutoff = url[prefix.length..-1].length > 30
<<~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
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(entity[:url])
@@ 122,7 122,7 @@ class TextFormatter
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
<<~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
end
M app/models/account_conversation.rb => app/models/account_conversation.rb +2 -8
@@ 32,14 32,8 @@ class AccountConversation < ApplicationRecord
end
def participant_accounts
- @participant_accounts ||= begin
- if participant_account_ids.empty?
- [account]
- else
- participants = Account.where(id: participant_account_ids).to_a
- participants.empty? ? [account] : participants
- end
- end
+ @participant_accounts ||= Account.where(id: participant_account_ids).to_a
+ @participant_accounts.presence || [account]
end
class << self
M app/models/user_settings.rb => app/models/user_settings.rb +5 -2
@@ 15,7 15,7 @@ class UserSettings
setting :show_application, default: true
setting :default_language, default: nil
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 :hide_followers_count, default: false
@@ 79,7 79,10 @@ class UserSettings
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?
@original_hash.delete(key)
M app/models/webhook.rb => app/models/webhook.rb +22 -0
@@ 24,6 24,8 @@ class Webhook < ApplicationRecord
status.updated
).freeze
+ attr_writer :current_account
+
scope :enabled, -> { where(enabled: true) }
validates :url, presence: true, url: true
@@ 31,6 33,7 @@ class Webhook < ApplicationRecord
validates :events, presence: true
validate :validate_events
+ validate :validate_permissions
validate :validate_template
before_validation :strip_events
@@ 48,12 51,31 @@ class Webhook < ApplicationRecord
update!(enabled: false)
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
def validate_events
errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
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
return if template.blank?
M app/policies/webhook_policy.rb => app/policies/webhook_policy.rb +2 -2
@@ 14,7 14,7 @@ class WebhookPolicy < ApplicationPolicy
end
def update?
- role.can?(:manage_webhooks)
+ role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end
def enable?
@@ 30,6 30,6 @@ class WebhookPolicy < ApplicationPolicy
end
def destroy?
- role.can?(:manage_webhooks)
+ role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end
end
M app/services/remove_status_service.rb => app/services/remove_status_service.rb +17 -1
@@ 12,6 12,7 @@ class RemoveStatusService < BaseService
# @option [Boolean] :immediate
# @option [Boolean] :preserve
# @option [Boolean] :original_removed
+ # @option [Boolean] :skip_streaming
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status
@@ 53,6 54,9 @@ class RemoveStatusService < BaseService
private
+ # The following FeedManager calls all do not result in redis publishes for
+ # streaming, as the `:update` option is false
+
def remove_from_self
FeedManager.instance.unpush_from_home(@account, @status)
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
# that may not follow the account
+ return if skip_streaming?
+
@status.active_mentions.find_each do |mention|
redis.publish("timeline:#{mention.account_id}", @payload)
end
@@ 105,7 111,7 @@ class RemoveStatusService < BaseService
# 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|
- RemoveStatusService.new.call(reblog, original_removed: true)
+ RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?)
end
end
@@ 116,6 122,8 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility?
+ return if skip_streaming?
+
@status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
@@ 125,6 133,8 @@ class RemoveStatusService < BaseService
def remove_from_public
return unless @status.public_visibility?
+ return if skip_streaming?
+
redis.publish('timeline:public', @payload)
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
end
@@ 132,6 142,8 @@ class RemoveStatusService < BaseService
def remove_from_media
return unless @status.public_visibility?
+ return if skip_streaming?
+
redis.publish('timeline:public:media', @payload)
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
end
@@ 151,4 163,8 @@ class RemoveStatusService < BaseService
def permanently?
@options[:immediate] || !(@options[:preserve] || @status.reported?)
end
+
+ def skip_streaming?
+ !!@options[:skip_streaming]
+ end
end
M app/views/admin/webhooks/_form.html.haml => app/views/admin/webhooks/_form.html.haml +1 -1
@@ 5,7 5,7 @@
= f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
.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
= f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }
M app/workers/scheduler/user_cleanup_scheduler.rb => app/workers/scheduler/user_cleanup_scheduler.rb +1 -1
@@ 24,7 24,7 @@ class Scheduler::UserCleanupScheduler
def clean_discarded_statuses!
Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
RemovalWorker.push_bulk(statuses) do |status|
- [status.id, { 'immediate' => true }]
+ [status.id, { 'immediate' => true, 'skip_streaming' => true }]
end
end
end
M config/boot.rb => config/boot.rb +1 -9
@@ 6,12 6,4 @@ end
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup' # Set up gems listed in the Gemfile.
-require 'bootsnap' # 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
-)
+require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
M config/locales/activerecord.en.yml => config/locales/activerecord.en.yml +4 -0
@@ 53,3 53,7 @@ en:
position:
elevated: cannot be higher than 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
M config/routes/api.rb => config/routes/api.rb +1 -0
@@ 82,6 82,7 @@ namespace :api, format: false do
resources :conversations, only: [:index, :destroy] do
member do
post :read
+ post :unread
end
end
M lib/sanitize_ext/sanitize_config.rb => lib/sanitize_ext/sanitize_config.rb +10 -3
@@ 55,6 55,11 @@ class Sanitize
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|
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),
attributes: {
- 'a' => %w(href rel class title),
+ 'a' => %w(href rel class title translate),
'abbr' => %w(title),
- 'span' => %w(class),
+ 'span' => %w(class translate),
'blockquote' => %w(cite),
'ol' => %w(start reversed),
'li' => %w(value),
@@ 96,6 101,7 @@ class Sanitize
transformers: [
CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER,
+ TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER,
]
)
@@ 151,7 157,7 @@ class Sanitize
MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
attributes: merge(
MASTODON_STRICT[:attributes],
- 'a' => %w(href rel class title target)
+ 'a' => %w(href rel class title target translate)
),
add_attributes: {},
@@ 159,6 165,7 @@ class Sanitize
transformers: [
CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER,
+ TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER,
LINK_REL_TRANSFORMER,
LINK_TARGET_TRANSFORMER,
M spec/controllers/admin/change_emails_controller_spec.rb => spec/controllers/admin/change_emails_controller_spec.rb +2 -1
@@ 23,7 23,8 @@ RSpec.describe Admin::ChangeEmailsController do
describe 'GET #update' 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
it 'returns http success' do
M spec/controllers/admin/confirmations_controller_spec.rb => spec/controllers/admin/confirmations_controller_spec.rb +1 -1
@@ 38,7 38,7 @@ RSpec.describe Admin::ConfirmationsController do
let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
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
context 'when email is not confirmed' do
M spec/controllers/admin/disputes/appeals_controller_spec.rb => spec/controllers/admin/disputes/appeals_controller_spec.rb +4 -2
@@ 19,7 19,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
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 }
end
@@ 40,7 41,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
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 }
end
M spec/controllers/admin/domain_allows_controller_spec.rb => spec/controllers/admin/domain_allows_controller_spec.rb +1 -1
@@ 37,7 37,7 @@ RSpec.describe Admin::DomainAllowsController do
describe 'DELETE #destroy' do
it 'disallows the domain' do
- service = double(call: true)
+ service = instance_double(UnallowDomainService, call: true)
allow(UnallowDomainService).to receive(:new).and_return(service)
domain_allow = Fabricate(:domain_allow)
delete :destroy, params: { id: domain_allow.id }
M spec/controllers/admin/domain_blocks_controller_spec.rb => spec/controllers/admin/domain_blocks_controller_spec.rb +1 -1
@@ 213,7 213,7 @@ RSpec.describe Admin::DomainBlocksController do
describe 'DELETE #destroy' do
it 'unblocks the domain' do
- service = double(call: true)
+ service = instance_double(UnblockDomainService, call: true)
allow(UnblockDomainService).to receive(:new).and_return(service)
domain_block = Fabricate(:domain_block)
delete :destroy, params: { id: domain_block.id }
M spec/controllers/admin/reports/actions_controller_spec.rb => spec/controllers/admin/reports/actions_controller_spec.rb +12 -18
@@ 62,17 62,10 @@ describe Admin::Reports::ActionsController do
end
shared_examples 'common behavior' do
- it 'closes the report' do
- expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
- end
+ it 'closes the report and redirects' do
+ expect { subject }.to mark_report_action_taken.and create_target_account_strike
- 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
- end
-
- it 'redirects' do
- subject
expect(response).to redirect_to(admin_reports_path)
end
@@ 81,20 74,21 @@ describe Admin::Reports::ActionsController do
{ report_id: report.id }
end
- it 'closes the report' do
- expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
- end
+ it 'closes the report and redirects' do
+ expect { subject }.to mark_report_action_taken.and create_target_account_strike
- 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 ''
- end
-
- it 'redirects' do
- subject
expect(response).to redirect_to(admin_reports_path)
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
shared_examples 'all action types' do
M spec/controllers/admin/webhooks_controller_spec.rb => spec/controllers/admin/webhooks_controller_spec.rb +2 -2
@@ 48,7 48,7 @@ describe Admin::WebhooksController do
end
context 'with an existing record' do
- let!(:webhook) { Fabricate :webhook }
+ let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
describe 'GET #show' do
it 'returns http success and renders view' do
@@ 82,7 82,7 @@ describe Admin::WebhooksController do
end.to_not change(webhook, :url)
expect(response).to have_http_status(:success)
- expect(response).to render_template(:show)
+ expect(response).to render_template(:edit)
end
end
D spec/controllers/api/v1/admin/account_actions_controller_spec.rb => spec/controllers/api/v1/admin/account_actions_controller_spec.rb +0 -55
@@ 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
M spec/controllers/api/v1/conversations_controller_spec.rb => spec/controllers/api/v1/conversations_controller_spec.rb +4 -2
@@ 18,6 18,7 @@ RSpec.describe Api::V1::ConversationsController do
before do
PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
+ PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct')
end
it 'returns http success' do
@@ 33,7 34,8 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do
get :index
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
context 'with since_id' do
@@ 41,7 43,7 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do
get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
json = body_as_json
- expect(json.size).to eq 1
+ expect(json.size).to eq 2
end
end
M spec/controllers/api/v1/notifications_controller_spec.rb => spec/controllers/api/v1/notifications_controller_spec.rb +20 -31
@@ 67,24 67,13 @@ RSpec.describe Api::V1::NotificationsController do
get :index
end
- it 'returns http success' do
+ it 'returns expected notification types', :aggregate_failures do
expect(response).to have_http_status(200)
- end
-
- it 'includes reblog' do
- expect(body_as_json.pluck(:type)).to include 'reblog'
- end
-
- 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'
+ expect(body_json_types).to include 'reblog'
+ expect(body_json_types).to include 'mention'
+ expect(body_json_types).to include 'favourite'
+ expect(body_json_types).to include 'follow'
end
end
@@ 93,12 82,14 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: third.account.id }
end
- it 'returns http success' do
+ it 'returns only notifications from specified user', :aggregate_failures do
expect(response).to have_http_status(200)
+
+ expect(body_json_account_ids.uniq).to eq [third.account.id.to_s]
end
- it 'returns only notifications from specified user' do
- expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s]
+ def body_json_account_ids
+ body_as_json.map { |x| x[:account][:id] }
end
end
@@ 107,27 98,23 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: 'foo' }
end
- it 'returns http success' do
+ it 'returns nothing', :aggregate_failures do
expect(response).to have_http_status(200)
- end
- it 'returns nothing' do
expect(body_as_json.size).to eq 0
end
end
- describe 'with excluded_types param' do
+ describe 'with exclude_types param' do
before do
get :index, params: { exclude_types: %w(mention) }
end
- it 'returns http success' do
+ it 'returns everything but excluded type', :aggregate_failures do
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.pluck(:type).uniq).to_not include 'mention'
+ expect(body_json_types.uniq).to_not include 'mention'
end
end
@@ 136,13 123,15 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { types: %w(mention) }
end
- it 'returns http success' do
+ it 'returns only requested type', :aggregate_failures do
expect(response).to have_http_status(200)
- end
- it 'returns only requested type' do
- expect(body_as_json.pluck(:type).uniq).to eq ['mention']
+ expect(body_json_types.uniq).to eq ['mention']
end
end
+
+ def body_json_types
+ body_as_json.pluck(:type)
+ end
end
end
M spec/controllers/api/v1/reports_controller_spec.rb => spec/controllers/api/v1/reports_controller_spec.rb +2 -1
@@ 23,7 23,8 @@ RSpec.describe Api::V1::ReportsController do
let(:rule_ids) { nil }
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 }
end
M spec/controllers/api/v1/statuses/histories_controller_spec.rb => spec/controllers/api/v1/statuses/histories_controller_spec.rb +1 -0
@@ 23,6 23,7 @@ describe Api::V1::Statuses::HistoriesController do
it 'returns http success' do
expect(response).to have_http_status(200)
+ expect(body_as_json.size).to_not be 0
end
end
end
D spec/controllers/api/v1/suggestions_controller_spec.rb => spec/controllers/api/v1/suggestions_controller_spec.rb +0 -37
@@ 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
D spec/controllers/api/v1/tags_controller_spec.rb => spec/controllers/api/v1/tags_controller_spec.rb +0 -88
@@ 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
M spec/controllers/api/v2/admin/accounts_controller_spec.rb => spec/controllers/api/v2/admin/accounts_controller_spec.rb +8 -0
@@ 55,5 55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do
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
M spec/controllers/api/web/embeds_controller_spec.rb => spec/controllers/api/web/embeds_controller_spec.rb +1 -1
@@ 26,7 26,7 @@ describe Api::Web::EmbedsController do
context 'when fails to find status' do
let(:url) { 'https://host.test/oembed.html' }
- let(:service_instance) { double('fetch_oembed_service') }
+ let(:service_instance) { instance_double(FetchOEmbedService) }
before do
allow(FetchOEmbedService).to receive(:new) { service_instance }
M spec/controllers/auth/sessions_controller_spec.rb => spec/controllers/auth/sessions_controller_spec.rb +2 -1
@@ 127,7 127,8 @@ RSpec.describe Auth::SessionsController do
before do
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)
post :create, params: { user: { email: user.email, password: user.password } }
end
M spec/controllers/authorize_interactions_controller_spec.rb => spec/controllers/authorize_interactions_controller_spec.rb +5 -5
@@ 28,7 28,7 @@ describe AuthorizeInteractionsController do
end
it 'renders error when account cant be found' do
- service = double
+ service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('missing@hostname').and_return(nil)
@@ 40,7 40,7 @@ describe AuthorizeInteractionsController do
it 'sets resource from url' do
account = Fabricate(:account)
- service = double
+ service = instance_double(ResolveURLService)
allow(ResolveURLService).to receive(:new).and_return(service)
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
account = Fabricate(:account)
- service = double
+ service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('found@hostname').and_return(account)
@@ 82,7 82,7 @@ describe AuthorizeInteractionsController do
end
it 'shows error when account not found' do
- service = double
+ service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(nil)
@@ 94,7 94,7 @@ describe AuthorizeInteractionsController do
it 'follows account when found' do
target_account = Fabricate(:account)
- service = double
+ service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(target_account)
M spec/controllers/disputes/appeals_controller_spec.rb => spec/controllers/disputes/appeals_controller_spec.rb +2 -1
@@ 14,7 14,8 @@ RSpec.describe Disputes::AppealsController do
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
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' } }
end
M spec/controllers/statuses_controller_spec.rb => spec/controllers/statuses_controller_spec.rb +19 -214
@@ 75,23 75,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 100,25 88,13 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
- expect(response).to have_http_status(200)
- end
+ it_behaves_like 'cacheable response'
- it 'returns Link 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'
- end
-
- it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
- end
-
- it_behaves_like 'cacheable response'
-
- it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 199,23 175,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 224,27 188,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
+ it 'renders ActivityPub Note object successfully', :aggregate_failures 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 'returns private Cache-Control header' do
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'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 263,23 212,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 288,27 225,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
+ it 'renders ActivityPub Note object successfully', :aggregate_failures 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 'returns private Cache-Control header' do
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'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 350,23 272,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 375,27 285,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
+ it 'renders ActivityPub Note object successfully' 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 'returns private Cache-Control header' do
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'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 463,23 358,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 488,25 371,13 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
- expect(response).to have_http_status(200)
- end
+ it_behaves_like 'cacheable response'
- it 'returns Link 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'
- end
-
- it 'returns Vary header' do
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
- end
-
- it_behaves_like 'cacheable response'
-
- it 'returns Content-Type header' do
expect(response.headers['Content-Type']).to include 'application/activity+json'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 525,23 396,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 550,27 409,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
+ it 'renders ActivityPub Note object successfully' 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 'returns private Cache-Control header' do
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'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 612,23 456,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
- end
-
- it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@@ 637,27 469,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
- it 'returns http success' do
+ it 'renders ActivityPub Note object', :aggregate_failures 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 'returns private Cache-Control header' do
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'
- end
-
- it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@@ 933,23 750,11 @@ describe StatusesController do
get :embed, params: { account_username: status.account.username, id: status.id }
end
- it 'returns http success' do
+ it 'renders status successfully', :aggregate_failures 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 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public'
- end
-
- it 'renders status' do
expect(response).to render_template(:embed)
expect(response.body).to include status.text
end
M spec/helpers/statuses_helper_spec.rb => spec/helpers/statuses_helper_spec.rb +17 -17
@@ 117,42 117,42 @@ describe StatusesHelper do
describe '#style_classes' do
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry'
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry entry-reblog'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, true, false, false)
expect(classes).to eq 'entry entry-predecessor'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, true, false)
expect(classes).to eq 'entry entry-successor'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, true)
expect(classes).to eq 'entry entry-center'
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, true, true, true)
expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
@@ 161,35 161,35 @@ describe StatusesHelper do
describe '#microformats_classes' do
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, false)
expect(classes).to eq ''
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, true)
expect(classes).to eq 'p-comment'
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to p-repost-of'
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, true)
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
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-entry'
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-cite'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, true, false, false)
expect(css_class).to eq 'h-cite'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, true, false)
expect(css_class).to eq 'h-cite'
end
it do
- status = double(reblog?: false)
+ status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, true)
expect(css_class).to eq ''
end
it do
- status = double(reblog?: true)
+ status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, true, true, true)
expect(css_class).to eq 'h-cite'
M spec/lib/activitypub/activity/add_spec.rb => spec/lib/activitypub/activity/add_spec.rb +1 -1
@@ 26,7 26,7 @@ RSpec.describe ActivityPub::Activity::Add do
end
context 'when status was not known before' do
- let(:service_stub) { double }
+ let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) }
let(:json) do
{
M spec/lib/activitypub/activity/move_spec.rb => spec/lib/activitypub/activity/move_spec.rb +1 -1
@@ 26,7 26,7 @@ RSpec.describe ActivityPub::Activity::Move do
stub_request(:post, old_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(service_stub).to receive(:call).and_return(returned_account)
end
M spec/lib/request_pool_spec.rb => spec/lib/request_pool_spec.rb +16 -7
@@ 48,16 48,25 @@ describe RequestPool do
expect(subject.size).to be > 1
end
- it 'closes idle connections' do
- stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
+ 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!')
+ end
- subject.with('http://example.com') do |http_client|
- http_client.get('/').flush
+ it 'closes the connections' do
+ subject.with('http://example.com') do |http_client|
+ http_client.get('/').flush
+ end
+
+ expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0)
end
- expect(subject.size).to eq 1
- sleep RequestPool::MAX_IDLE_TIME + 30 + 1
- 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
M spec/lib/request_spec.rb => spec/lib/request_spec.rb +2 -2
@@ 48,7 48,7 @@ describe Request do
end
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(:timeouts=).and_return(nil)
@@ 83,7 83,7 @@ describe Request do
end
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(:timeouts=).and_return(nil)
M spec/lib/sanitize_config_spec.rb => spec/lib/sanitize_config_spec.rb +8 -0
@@ 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>'
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
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
end
M spec/lib/suspicious_sign_in_detector_spec.rb => spec/lib/suspicious_sign_in_detector_spec.rb +1 -1
@@ 7,7 7,7 @@ RSpec.describe SuspiciousSignInDetector do
subject { described_class.new(user).suspicious?(request) }
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 }
context 'when user has 2FA enabled' do
M spec/mailers/user_mailer_spec.rb => spec/mailers/user_mailer_spec.rb +55 -0
@@ 142,4 142,59 @@ describe UserMailer do
expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title')
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
M spec/models/account/field_spec.rb => spec/models/account/field_spec.rb +3 -3
@@ 6,7 6,7 @@ RSpec.describe Account::Field do
describe '#verified?' do
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
let(:verified_at) { Time.now.utc.iso8601 }
@@ 28,7 28,7 @@ RSpec.describe Account::Field do
describe '#mark_verified!' do
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' } }
before do
@@ 47,7 47,7 @@ RSpec.describe Account::Field do
describe '#verifiable?' do
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
let(:local) { true }
M spec/models/account_migration_spec.rb => spec/models/account_migration_spec.rb +2 -2
@@ 15,7 15,7 @@ RSpec.describe AccountMigration do
before do
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(service_double).to receive(:call).with(target_acct, anything).and_return(target_account)
end
@@ 29,7 29,7 @@ RSpec.describe AccountMigration do
let(:target_acct) { 'target@remote' }
before do
- service_double = double
+ service_double = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service_double)
allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil)
end
M spec/models/session_activation_spec.rb => spec/models/session_activation_spec.rb +2 -2
@@ 16,7 16,7 @@ RSpec.describe SessionActivation do
allow(session_activation).to receive(:detection).and_return(detection)
end
- let(:detection) { double(id: 1) }
+ let(:detection) { instance_double(Browser::Chrome, id: 1) }
let(:session_activation) { Fabricate(:session_activation) }
it 'returns detection.id' do
@@ 30,7 30,7 @@ RSpec.describe SessionActivation do
end
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
expect(session_activation.platform).to be 1
M spec/models/setting_spec.rb => spec/models/setting_spec.rb +1 -1
@@ 62,7 62,7 @@ RSpec.describe Setting do
context 'when RailsSettings::Settings.object returns truthy' do
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
let(:default_value) { { default_value: 'default_value' } }
M spec/models/user_settings_spec.rb => spec/models/user_settings_spec.rb +10 -0
@@ 49,6 49,16 @@ RSpec.describe UserSettings do
expect(subject[:always_send_emails]).to be true
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
describe '#update' do
M spec/policies/webhook_policy_spec.rb => spec/policies/webhook_policy_spec.rb +19 -3
@@ 8,16 8,32 @@ describe WebhookPolicy do
let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).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
it 'permits' do
- expect(policy).to permit(admin, Tag)
+ expect(policy).to permit(admin, Webhook)
end
end
context 'with a non-admin' 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
A spec/requests/api/v1/admin/account_actions_spec.rb => spec/requests/api/v1/admin/account_actions_spec.rb +154 -0
@@ 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
A spec/requests/api/v1/suggestions_spec.rb => spec/requests/api/v1/suggestions_spec.rb +103 -0
@@ 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
A spec/requests/api/v1/tags_spec.rb => spec/requests/api/v1/tags_spec.rb +169 -0
@@ 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
M spec/services/account_search_service_spec.rb => spec/services/account_search_service_spec.rb +2 -2
@@ 53,7 53,7 @@ describe AccountSearchService, type: :service do
context 'when there is a domain but no exact match' 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)
results = subject.call('newuser@remote.com', nil, limit: 10, resolve: true)
@@ 61,7 61,7 @@ describe AccountSearchService, type: :service do
end
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)
results = subject.call('newuser@remote.com', nil, limit: 10, resolve: false)
M spec/services/backup_service_spec.rb => spec/services/backup_service_spec.rb +47 -34
@@ 30,7 30,7 @@ RSpec.describe BackupService, type: :service do
it 'stores them as expected' do
service_call
- json = Oj.load(read_zip_file(backup, 'actor.json'))
+ json = export_json(:actor)
avatar_path = json.dig('icon', 'url')
header_path = json.dig('image', 'url')
@@ 42,47 42,60 @@ RSpec.describe BackupService, type: :service do
end
end
- it 'marks the backup as processed' do
- expect { service_call }.to change(backup, :processed).from(false).to(true)
+ it 'marks the backup as processed and exports files' do
+ expect { service_call }.to process_backup
+
+ expect_outbox_export
+ expect_likes_export
+ expect_bookmarks_export
end
- it 'exports outbox.json as expected' do
- service_call
+ def process_backup
+ change(backup, :processed).from(false).to(true)
+ end
- json = Oj.load(read_zip_file(backup, 'outbox.json'))
- expect(json['@context']).to_not be_nil
- expect(json['type']).to eq 'OrderedCollection'
- expect(json['totalItems']).to eq 2
- expect(json['orderedItems'][0]['@context']).to be_nil
- expect(json['orderedItems'][0]).to include({
- 'type' => 'Create',
- 'object' => include({
- '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>',
- }),
- })
+ def expect_outbox_export
+ json = export_json(:outbox)
+
+ aggregate_failures do
+ expect(json['@context']).to_not be_nil
+ expect(json['type']).to eq 'OrderedCollection'
+ expect(json['totalItems']).to eq 2
+ expect(json['orderedItems'][0]['@context']).to be_nil
+ expect(json['orderedItems'][0]).to include_create_item(status)
+ expect(json['orderedItems'][1]).to include_create_item(private_status)
+ end
+ end
+
+ def expect_likes_export
+ json = export_json(:likes)
+
+ aggregate_failures do
+ expect(json['type']).to eq 'OrderedCollection'
+ expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
+ end
end
- it 'exports likes.json as expected' do
- service_call
+ def expect_bookmarks_export
+ json = export_json(:bookmarks)
- json = Oj.load(read_zip_file(backup, 'likes.json'))
- expect(json['type']).to eq 'OrderedCollection'
- expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(favourite.status)]
+ aggregate_failures do
+ expect(json['type']).to eq 'OrderedCollection'
+ expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
+ end
end
- it 'exports bookmarks.json as expected' do
- service_call
+ def export_json(type)
+ Oj.load(read_zip_file(backup, "#{type}.json"))
+ end
- json = Oj.load(read_zip_file(backup, 'bookmarks.json'))
- expect(json['type']).to eq 'OrderedCollection'
- expect(json['orderedItems']).to eq [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
+ def include_create_item(status)
+ include({
+ 'type' => 'Create',
+ 'object' => include({
+ 'id' => ActivityPub::TagManager.instance.uri_for(status),
+ 'content' => "<p>#{status.text}</p>",
+ }),
+ })
end
end
M spec/services/bootstrap_timeline_service_spec.rb => spec/services/bootstrap_timeline_service_spec.rb +1 -1
@@ 6,7 6,7 @@ RSpec.describe BootstrapTimelineService, type: :service do
subject { described_class.new }
context 'when the new user has registered from an invite' do
- let(:service) { double }
+ let(:service) { instance_double(FollowService) }
let(:autofollow) { false }
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) }
M spec/services/bulk_import_service_spec.rb => spec/services/bulk_import_service_spec.rb +8 -8
@@ 47,7 47,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the listed users once the workers have run' do
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(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) }
@@ 95,7 95,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do
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(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) }
@@ 133,7 133,7 @@ RSpec.describe BulkImportService do
it 'blocks all the listed users once the workers have run' do
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(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) }
@@ 177,7 177,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do
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(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) }
@@ 215,7 215,7 @@ RSpec.describe BulkImportService do
it 'mutes all the listed users once the workers have run' do
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(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) }
@@ 263,7 263,7 @@ RSpec.describe BulkImportService do
it 'requests to follow all the expected users once the workers have run' do
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(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) }
@@ 360,7 360,7 @@ RSpec.describe BulkImportService do
it 'updates the bookmarks as expected once the workers have run' do
subject.call(import)
- service_double = double
+ service_double = instance_double(ActivityPub::FetchRemoteStatusService)
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/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
subject.call(import)
- service_double = double
+ service_double = instance_double(ActivityPub::FetchRemoteStatusService)
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/private') { Fabricate(:status, uri: 'https://domain.unknown/private', visibility: :direct) }
M spec/services/fetch_resource_service_spec.rb => spec/services/fetch_resource_service_spec.rb +2 -2
@@ 24,7 24,7 @@ RSpec.describe FetchResourceService, type: :service do
context 'when OpenSSL::SSL::SSLError is raised' do
before do
- request = double
+ request = instance_double(Request)
allow(Request).to receive(:new).and_return(request)
allow(request).to receive(:add_headers)
allow(request).to receive(:on_behalf_of)
@@ 36,7 36,7 @@ RSpec.describe FetchResourceService, type: :service do
context 'when HTTP::ConnectionError is raised' do
before do
- request = double
+ request = instance_double(Request)
allow(Request).to receive(:new).and_return(request)
allow(request).to receive(:add_headers)
allow(request).to receive(:on_behalf_of)
M spec/services/import_service_spec.rb => spec/services/import_service_spec.rb +1 -1
@@ 219,7 219,7 @@ RSpec.describe ImportService, type: :service do
end
before do
- service = double
+ service = instance_double(ActivityPub::FetchRemoteStatusService)
allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service)
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')
M spec/services/post_status_service_spec.rb => spec/services/post_status_service_spec.rb +2 -2
@@ 132,7 132,7 @@ RSpec.describe PostStatusService, type: :service do
end
it 'processes mentions' do
- mention_service = double(:process_mentions_service)
+ mention_service = instance_double(ProcessMentionsService)
allow(mention_service).to receive(:call)
allow(ProcessMentionsService).to receive(:new).and_return(mention_service)
account = Fabricate(:account)
@@ 163,7 163,7 @@ RSpec.describe PostStatusService, type: :service do
end
it 'processes hashtags' do
- hashtags_service = double(:process_hashtags_service)
+ hashtags_service = instance_double(ProcessHashtagsService)
allow(hashtags_service).to receive(:call)
allow(ProcessHashtagsService).to receive(:new).and_return(hashtags_service)
account = Fabricate(:account)
M spec/services/resolve_url_service_spec.rb => spec/services/resolve_url_service_spec.rb +2 -2
@@ 9,7 9,7 @@ describe ResolveURLService, type: :service do
it 'returns nil when there is no resource url' do
url = 'http://example.com/missing-resource'
known_account = Fabricate(:account, uri: url)
- service = double
+ service = instance_double(FetchResourceService)
allow(FetchResourceService).to receive(:new).and_return service
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
url = 'http://example.com/missing-resource'
known_account = Fabricate(:account, uri: url)
- service = double
+ service = instance_double(FetchResourceService)
allow(FetchResourceService).to receive(:new).and_return service
allow(service).to receive(:response_code).and_return(500)
M spec/services/search_service_spec.rb => spec/services/search_service_spec.rb +4 -4
@@ 25,7 25,7 @@ describe SearchService, type: :service do
context 'when it does not find anything' 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)
results = subject.call(@query, nil, 10, resolve: true)
@@ 37,7 37,7 @@ describe SearchService, type: :service do
context 'when it finds an account' do
it 'includes the account in the results' do
account = Account.new
- service = double(call: account)
+ service = instance_double(ResolveURLService, call: account)
allow(ResolveURLService).to receive(:new).and_return(service)
results = subject.call(@query, nil, 10, resolve: true)
@@ 49,7 49,7 @@ describe SearchService, type: :service do
context 'when it finds a status' do
it 'includes the status in the results' do
status = Status.new
- service = double(call: status)
+ service = instance_double(ResolveURLService, call: status)
allow(ResolveURLService).to receive(:new).and_return(service)
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
query = 'username'
account = Account.new
- service = double(call: [account])
+ service = instance_double(AccountSearchService, call: [account])
allow(AccountSearchService).to receive(:new).and_return(service)
results = subject.call(query, nil, 10)
M spec/services/unsuspend_account_service_spec.rb => spec/services/unsuspend_account_service_spec.rb +1 -1
@@ 63,7 63,7 @@ RSpec.describe UnsuspendAccountService, type: :service do
describe 'unsuspending a remote account' 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!(:resolve_account_service) { double }
+ let!(:resolve_account_service) { instance_double(ResolveAccountService) }
before do
allow(ResolveAccountService).to receive(:new).and_return(resolve_account_service)
M spec/validators/blacklisted_email_validator_spec.rb => spec/validators/blacklisted_email_validator_spec.rb +2 -2
@@ 6,8 6,8 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
describe '#validate' do
subject { described_class.new.validate(user); errors }
- let(:user) { double(email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) }
- let(:errors) { double(add: nil) }
+ let(:user) { instance_double(User, email: 'info@mail.com', sign_up_ip: '1.2.3.4', errors: errors) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
before do
allow(user).to receive(:valid_invitation?).and_return(false)
M spec/validators/disallowed_hashtags_validator_spec.rb => spec/validators/disallowed_hashtags_validator_spec.rb +2 -2
@@ 11,8 11,8 @@ RSpec.describe DisallowedHashtagsValidator, type: :validator do
described_class.new.validate(status)
end
- let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
- let(:errors) { double(add: nil) }
+ let(:status) { instance_double(Status, errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| "##{x}" }.join(' ')) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
context 'with a remote reblog' do
let(:local) { false }
M spec/validators/email_mx_validator_spec.rb => spec/validators/email_mx_validator_spec.rb +18 -14
@@ 4,7 4,7 @@ require 'rails_helper'
describe EmailMxValidator 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
around do |block|
@@ 15,7 15,7 @@ describe EmailMxValidator do
end
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::A).and_return([])
@@ 29,7 29,7 @@ describe EmailMxValidator do
end
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::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(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)
expect(user.errors).to have_received(:add)
end
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)
expect(user.errors).to have_received(:add)
end
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::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(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)
expect(user.errors).to have_received(:add)
end
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::A).and_return([])
@@ 85,9 85,11 @@ describe EmailMxValidator do
end
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::AAAA).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
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::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::AAAA).and_return([double(address: 'fd00::2')])
+ 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([instance_double(Resolv::DNS::Resource::IN::A, address: 'fd00::2')])
allow(resolver).to receive(:timeouts=).and_return(nil)
allow(Resolv::DNS).to receive(:open).and_yield(resolver)
M spec/validators/follow_limit_validator_spec.rb => spec/validators/follow_limit_validator_spec.rb +3 -3
@@ 12,9 12,9 @@ RSpec.describe FollowLimitValidator, type: :validator do
described_class.new.validate(follow)
end
- let(:follow) { double(account: account, errors: errors) }
- let(:errors) { double(add: nil) }
- let(:account) { double(nil?: _nil, local?: local, following_count: 0, followers_count: 0) }
+ let(:follow) { instance_double(Follow, account: account, errors: errors) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
+ let(:account) { instance_double(Account, nil?: _nil, local?: local, following_count: 0, followers_count: 0) }
let(:_nil) { true }
let(:local) { false }
M spec/validators/note_length_validator_spec.rb => spec/validators/note_length_validator_spec.rb +9 -3
@@ 8,7 8,7 @@ describe NoteLengthValidator do
describe '#validate' do
it 'adds an error when text is over 500 characters' do
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)
expect(account.errors).to have_received(:add)
@@ 16,7 16,7 @@ describe NoteLengthValidator do
it 'counts URLs as 23 characters flat' do
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)
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
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)
expect(account.errors).to have_received(:add)
end
+
+ private
+
+ def activemodel_errors
+ instance_double(ActiveModel::Errors, add: nil)
+ end
end
end
M spec/validators/poll_validator_spec.rb => spec/validators/poll_validator_spec.rb +2 -2
@@ 9,8 9,8 @@ RSpec.describe PollValidator, type: :validator do
end
let(:validator) { described_class.new }
- let(:poll) { double(options: options, expires_at: expires_at, errors: errors) }
- let(:errors) { double(add: nil) }
+ let(:poll) { instance_double(Poll, options: options, expires_at: expires_at, errors: errors) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:options) { %w(foo bar) }
let(:expires_at) { 1.day.from_now }
M spec/validators/status_length_validator_spec.rb => spec/validators/status_length_validator_spec.rb +16 -10
@@ 5,27 5,27 @@ require 'rails_helper'
describe StatusLengthValidator do
describe '#validate' do
it 'does not add errors onto remote statuses' do
- status = double(local?: false)
+ status = instance_double(Status, local?: false)
subject.validate(status)
expect(status).to_not receive(:errors)
end
it 'does not add errors onto local reblogs' do
- status = double(local?: false, reblog?: true)
+ status = instance_double(Status, local?: false, reblog?: true)
subject.validate(status)
expect(status).to_not receive(:errors)
end
it 'adds an error when content warning is over MAX_CHARS characters' do
chars = StatusLengthValidator::MAX_CHARS + 1
- status = double(spoiler_text: 'a' * chars, text: '', errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: 'a' * chars, text: '', errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
end
it 'adds an error when text is over MAX_CHARS characters' do
chars = StatusLengthValidator::MAX_CHARS + 1
- status = double(spoiler_text: '', text: 'a' * chars, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: 'a' * chars, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
end
@@ 33,7 33,7 @@ describe StatusLengthValidator do
it 'adds an error when text and content warning are over MAX_CHARS characters total' do
chars1 = 20
chars2 = StatusLengthValidator::MAX_CHARS + 1 - chars1
- status = double(spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: 'a' * chars1, text: 'b' * chars2, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
end
@@ 41,7 41,7 @@ describe StatusLengthValidator do
it 'counts URLs as 23 characters flat' do
chars = StatusLengthValidator::MAX_CHARS - 1 - 23
text = ('a' * chars) + " http://#{'b' * 30}.com/example"
- status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to_not have_received(:add)
@@ 49,7 49,7 @@ describe StatusLengthValidator do
it 'does not count non-autolinkable URLs as 23 characters flat' do
text = ('a' * 476) + "http://#{'b' * 30}.com/example"
- status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
@@ 57,7 57,7 @@ describe StatusLengthValidator do
it 'does not count overly long URLs as 23 characters flat' do
text = "http://example.com/valid?#{'#foo?' * 1000}"
- status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
end
@@ 66,7 66,7 @@ describe StatusLengthValidator do
username = '@alice'
chars = StatusLengthValidator::MAX_CHARS - 1 - username.length
text = ('a' * chars) + " #{username}@#{'b' * 30}.com"
- status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to_not have_received(:add)
@@ 74,10 74,16 @@ describe StatusLengthValidator do
it 'does count both parts of remote usernames for overly long domains' do
text = "@alice@#{'b' * 500}.com"
- status = double(spoiler_text: '', text: text, errors: double(add: nil), local?: true, reblog?: false)
+ status = instance_double(Status, spoiler_text: '', text: text, errors: activemodel_errors, local?: true, reblog?: false)
subject.validate(status)
expect(status.errors).to have_received(:add)
end
end
+
+ private
+
+ def activemodel_errors
+ instance_double(ActiveModel::Errors, add: nil)
+ end
end
M spec/validators/status_pin_validator_spec.rb => spec/validators/status_pin_validator_spec.rb +5 -5
@@ 8,11 8,11 @@ RSpec.describe StatusPinValidator, type: :validator do
subject.validate(pin)
end
- let(:pin) { double(account: account, errors: errors, status: status, account_id: pin_account_id) }
- let(:status) { double(reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') }
- let(:account) { double(status_pins: status_pins, local?: local) }
- let(:status_pins) { double(count: count) }
- let(:errors) { double(add: nil) }
+ let(:pin) { instance_double(StatusPin, account: account, errors: errors, status: status, account_id: pin_account_id) }
+ let(:status) { instance_double(Status, reblog?: reblog, account_id: status_account_id, visibility: visibility, direct_visibility?: visibility == 'direct') }
+ let(:account) { instance_double(Account, status_pins: status_pins, local?: local) }
+ let(:status_pins) { instance_double(Array, count: count) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:pin_account_id) { 1 }
let(:status_account_id) { 1 }
let(:visibility) { 'public' }
M spec/validators/unique_username_validator_spec.rb => spec/validators/unique_username_validator_spec.rb +13 -7
@@ 6,7 6,7 @@ describe UniqueUsernameValidator do
describe '#validate' do
context 'when local account' do
it 'does not add errors if username is nil' do
- account = double(username: nil, domain: nil, persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: nil, domain: nil, persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
@@ 18,14 18,14 @@ describe UniqueUsernameValidator do
it 'adds an error when the username is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef')
- account = double(username: 'abcDEF', domain: nil, persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: 'abcDEF', domain: nil, persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to have_received(:add)
end
it 'does not add errors when same username remote account exists' do
Fabricate(:account, username: 'abcdef', domain: 'example.com')
- account = double(username: 'abcdef', domain: nil, persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: 'abcdef', domain: nil, persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
@@ 34,7 34,7 @@ describe UniqueUsernameValidator do
context 'when remote account' do
it 'does not add errors if username is nil' do
- account = double(username: nil, domain: 'example.com', persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: nil, domain: 'example.com', persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
@@ 46,23 46,29 @@ describe UniqueUsernameValidator do
it 'adds an error when the username is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef', domain: 'example.com')
- account = double(username: 'abcDEF', domain: 'example.com', persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: 'abcDEF', domain: 'example.com', persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to have_received(:add)
end
it 'adds an error when the domain is already used with ignoring cases' do
Fabricate(:account, username: 'ABCdef', domain: 'example.com')
- account = double(username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: 'ABCdef', domain: 'EXAMPLE.COM', persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to have_received(:add)
end
it 'does not add errors when account with the same username and another domain exists' do
Fabricate(:account, username: 'abcdef', domain: 'example.com')
- account = double(username: 'abcdef', domain: 'example2.com', persisted?: false, errors: double(add: nil))
+ account = instance_double(Account, username: 'abcdef', domain: 'example2.com', persisted?: false, errors: activemodel_errors)
subject.validate(account)
expect(account.errors).to_not have_received(:add)
end
end
+
+ private
+
+ def activemodel_errors
+ instance_double(ActiveModel::Errors, add: nil)
+ end
end
M spec/validators/unreserved_username_validator_spec.rb => spec/validators/unreserved_username_validator_spec.rb +2 -2
@@ 10,8 10,8 @@ RSpec.describe UnreservedUsernameValidator, type: :validator do
end
let(:validator) { described_class.new }
- let(:account) { double(username: username, errors: errors) }
- let(:errors) { double(add: nil) }
+ let(:account) { instance_double(Account, username: username, errors: errors) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
context 'when @username is blank?' do
let(:username) { nil }
M spec/validators/url_validator_spec.rb => spec/validators/url_validator_spec.rb +2 -2
@@ 10,8 10,8 @@ RSpec.describe URLValidator, type: :validator do
end
let(:validator) { described_class.new(attributes: [attribute]) }
- let(:record) { double(errors: errors) }
- let(:errors) { double(add: nil) }
+ let(:record) { instance_double(Webhook, errors: errors) }
+ let(:errors) { instance_double(ActiveModel::Errors, add: nil) }
let(:value) { '' }
let(:attribute) { :foo }
M spec/views/statuses/show.html.haml_spec.rb => spec/views/statuses/show.html.haml_spec.rb +1 -1
@@ 4,7 4,7 @@ require 'rails_helper'
describe 'statuses/show.html.haml', without_verify_partial_doubles: true do
before do
- double(api_oembed_url: '')
+ allow(view).to receive(:api_oembed_url).and_return('')
allow(view).to receive(:show_landing_strip?).and_return(true)
allow(view).to receive(:site_title).and_return('example site')
allow(view).to receive(:site_hostname).and_return('example.com')
M spec/workers/activitypub/processing_worker_spec.rb => spec/workers/activitypub/processing_worker_spec.rb +2 -1
@@ 9,7 9,8 @@ describe ActivityPub::ProcessingWorker do
describe '#perform' do
it 'delegates to ActivityPub::ProcessCollectionService' do
- allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil))
+ allow(ActivityPub::ProcessCollectionService).to receive(:new)
+ .and_return(instance_double(ActivityPub::ProcessCollectionService, call: nil))
subject.perform(account.id, '')
expect(ActivityPub::ProcessCollectionService).to have_received(:new)
end
M spec/workers/admin/domain_purge_worker_spec.rb => spec/workers/admin/domain_purge_worker_spec.rb +1 -1
@@ 7,7 7,7 @@ describe Admin::DomainPurgeWorker do
describe 'perform' do
it 'calls domain purge service for relevant domain block' do
- service = double(call: nil)
+ service = instance_double(PurgeDomainService, call: nil)
allow(PurgeDomainService).to receive(:new).and_return(service)
result = subject.perform('example.com')
M spec/workers/domain_block_worker_spec.rb => spec/workers/domain_block_worker_spec.rb +1 -1
@@ 9,7 9,7 @@ describe DomainBlockWorker do
let(:domain_block) { Fabricate(:domain_block) }
it 'calls domain block service for relevant domain block' do
- service = double(call: nil)
+ service = instance_double(BlockDomainService, call: nil)
allow(BlockDomainService).to receive(:new).and_return(service)
result = subject.perform(domain_block.id)
M spec/workers/domain_clear_media_worker_spec.rb => spec/workers/domain_clear_media_worker_spec.rb +1 -1
@@ 9,7 9,7 @@ describe DomainClearMediaWorker do
let(:domain_block) { Fabricate(:domain_block, severity: :silence, reject_media: true) }
it 'calls domain clear media service for relevant domain block' do
- service = double(call: nil)
+ service = instance_double(ClearDomainMediaService, call: nil)
allow(ClearDomainMediaService).to receive(:new).and_return(service)
result = subject.perform(domain_block.id)
M spec/workers/feed_insert_worker_spec.rb => spec/workers/feed_insert_worker_spec.rb +4 -4
@@ 11,7 11,7 @@ describe FeedInsertWorker do
context 'when there are no records' do
it 'skips push with missing status' do
- instance = double(push_to_home: nil)
+ instance = instance_double(FeedManager, push_to_home: nil)
allow(FeedManager).to receive(:instance).and_return(instance)
result = subject.perform(nil, follower.id)
@@ 20,7 20,7 @@ describe FeedInsertWorker do
end
it 'skips push with missing account' do
- instance = double(push_to_home: nil)
+ instance = instance_double(FeedManager, push_to_home: nil)
allow(FeedManager).to receive(:instance).and_return(instance)
result = subject.perform(status.id, nil)
@@ 31,7 31,7 @@ describe FeedInsertWorker do
context 'when there are real records' do
it 'skips the push when there is a filter' do
- instance = double(push_to_home: nil, filter?: true)
+ instance = instance_double(FeedManager, push_to_home: nil, filter?: true)
allow(FeedManager).to receive(:instance).and_return(instance)
result = subject.perform(status.id, follower.id)
@@ 40,7 40,7 @@ describe FeedInsertWorker do
end
it 'pushes the status onto the home timeline without filter' do
- instance = double(push_to_home: nil, filter?: false)
+ instance = instance_double(FeedManager, push_to_home: nil, filter?: false)
allow(FeedManager).to receive(:instance).and_return(instance)
result = subject.perform(status.id, follower.id)
M spec/workers/move_worker_spec.rb => spec/workers/move_worker_spec.rb +1 -1
@@ 15,7 15,7 @@ describe MoveWorker do
let!(:account_note) { Fabricate(:account_note, account: local_user.account, target_account: source_account, comment: comment) }
let(:list) { Fabricate(:list, account: local_follower) }
- let(:block_service) { double }
+ let(:block_service) { instance_double(BlockService) }
before do
stub_request(:post, 'https://example.org/a/inbox').to_return(status: 200)
M spec/workers/publish_scheduled_announcement_worker_spec.rb => spec/workers/publish_scheduled_announcement_worker_spec.rb +1 -1
@@ 12,7 12,7 @@ describe PublishScheduledAnnouncementWorker do
describe 'perform' do
before do
- service = double
+ service = instance_double(FetchRemoteStatusService)
allow(FetchRemoteStatusService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('https://domain.com/users/foo/12345') { remote_status.reload }
M spec/workers/refollow_worker_spec.rb => spec/workers/refollow_worker_spec.rb +1 -1
@@ 10,7 10,7 @@ describe RefollowWorker do
let(:bob) { Fabricate(:account, domain: nil, username: 'bob') }
describe 'perform' do
- let(:service) { double }
+ let(:service) { instance_double(FollowService) }
before do
allow(FollowService).to receive(:new).and_return(service)
M spec/workers/regeneration_worker_spec.rb => spec/workers/regeneration_worker_spec.rb +1 -1
@@ 9,7 9,7 @@ describe RegenerationWorker do
let(:account) { Fabricate(:account) }
it 'calls the precompute feed service for the account' do
- service = double(call: nil)
+ service = instance_double(PrecomputeFeedService, call: nil)
allow(PrecomputeFeedService).to receive(:new).and_return(service)
result = subject.perform(account.id)
M spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb => spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb +6 -0
@@ 75,6 75,12 @@ describe Scheduler::AccountsStatusesCleanupScheduler do
end
describe '#perform' do
+ around do |example|
+ Timeout.timeout(30) do
+ example.run
+ end
+ end
+
before do
# Policies for the accounts
Fabricate(:account_statuses_cleanup_policy, account: account_alice)
M yarn.lock => yarn.lock +6 -3
@@ 4724,9 4724,9 @@ domutils@^3.0.1:
domhandler "^5.0.3"
dotenv@^16.0.3:
- version "16.1.4"
- resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.1.4.tgz#67ac1a10cd9c25f5ba604e4e08bc77c0ebe0ca8c"
- integrity sha512-m55RtE8AsPeJBpOIFKihEmqUcoVncQIwo7x9U8ZwLEZw9ZpXboz2c+rvog+jUaJvVrZ5kBOeYQBX5+8Aa/OZQw==
+ version "16.3.1"
+ resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.3.1.tgz#369034de7d7e5b120972693352a3bf112172cc3e"
+ integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==
duplexer@^0.1.2:
version "0.1.2"
@@ 10747,6 10747,7 @@ string-length@^4.0.1:
strip-ansi "^6.0.0"
"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+ name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ 10845,6 10846,7 @@ stringz@^2.1.0:
char-regex "^1.0.2"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ 12246,6 12248,7 @@ workbox-window@7.0.0, workbox-window@^7.0.0:
workbox-core "7.0.0"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+ name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==