~cytrogen/masto-fe

65cbcce997946bedfe4137606a579b9cd3e9c6e9 — Claire 2 years ago fefdc32 + 39110d1
Merge commit '39110d1d0af5e3d9cf452ae47496a52797249fd0' into glitch-soc/merge-upstream
72 files changed, 1626 insertions(+), 1001 deletions(-)

M .rubocop_todo.yml
M Gemfile.lock
M app/controllers/concerns/captcha_concern.rb
A app/controllers/mail_subscriptions_controller.rb
A app/javascript/mastodon/components/circular_progress.tsx
M app/javascript/mastodon/components/dropdown_menu.jsx
D app/javascript/mastodon/components/load_pending.jsx
A app/javascript/mastodon/components/load_pending.tsx
D app/javascript/mastodon/components/loading_indicator.jsx
A app/javascript/mastodon/components/loading_indicator.tsx
M app/javascript/mastodon/components/scrollable_list.jsx
M app/javascript/mastodon/features/account/components/header.jsx
M app/javascript/mastodon/features/account_gallery/index.jsx
M app/javascript/mastodon/features/account_timeline/index.jsx
M app/javascript/mastodon/features/blocks/index.jsx
M app/javascript/mastodon/features/directory/index.jsx
M app/javascript/mastodon/features/domain_blocks/index.jsx
A app/javascript/mastodon/features/emoji/emoji_compressed.d.ts
M app/javascript/mastodon/features/emoji/emoji_compressed.js
R app/javascript/mastodon/features/emoji/{emoji_mart_data_light.js => emoji_mart_data_light.ts}
M app/javascript/mastodon/features/explore/links.jsx
M app/javascript/mastodon/features/explore/results.jsx
M app/javascript/mastodon/features/explore/suggestions.jsx
M app/javascript/mastodon/features/explore/tags.jsx
M app/javascript/mastodon/features/favourites/index.jsx
M app/javascript/mastodon/features/followers/index.jsx
M app/javascript/mastodon/features/following/index.jsx
M app/javascript/mastodon/features/list_timeline/index.jsx
M app/javascript/mastodon/features/lists/index.jsx
M app/javascript/mastodon/features/mutes/index.jsx
M app/javascript/mastodon/features/notifications/components/report.jsx
M app/javascript/mastodon/features/reblogs/index.jsx
M app/javascript/mastodon/features/report/statuses.jsx
M app/javascript/mastodon/features/status/index.jsx
M app/javascript/mastodon/features/ui/components/modal_loading.jsx
M app/javascript/mastodon/locales/en.json
M app/javascript/styles/mastodon/forms.scss
M app/mailers/notification_mailer.rb
M app/models/report.rb
M app/views/auth/confirmations/captcha.html.haml
M app/views/layouts/mailer.html.haml
A app/views/mail_subscriptions/create.html.haml
A app/views/mail_subscriptions/show.html.haml
M config/i18n-tasks.yml
M config/locales/en.yml
M config/locales/simple_form.en.yml
M config/routes.rb
M lib/mastodon/cli/cache.rb
M lib/mastodon/cli/feeds.rb
M spec/controllers/api/v1/accounts/relationships_controller_spec.rb
M spec/controllers/api/v1/accounts_controller_spec.rb
D spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb
D spec/controllers/api/v1/admin/reports_controller_spec.rb
M spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb
M spec/fabricators/domain_allow_fabricator.rb
A spec/fabricators/status_stat_fabricator.rb
M spec/lib/mastodon/cli/cache_spec.rb
M spec/lib/mastodon/cli/feeds_spec.rb
M spec/models/form/import_spec.rb
M spec/models/notification_spec.rb
M spec/rails_helper.rb
A spec/requests/api/v1/accounts_show_spec.rb
R spec/{controllers/api/v1/admin/canonical_email_blocks_controller_spec => requests/api/v1/admin/canonical_email_blocks_spec}.rb
R spec/{controllers/api/v1/admin/domain_allows_controller_spec => requests/api/v1/admin/domain_allows_spec}.rb
A spec/requests/api/v1/admin/domain_blocks_spec.rb
R spec/{controllers/api/v1/admin/ip_blocks_controller_spec => requests/api/v1/admin/ip_blocks_spec}.rb
A spec/requests/api/v1/admin/reports_spec.rb
M spec/services/translate_status_service_spec.rb
D spec/support/examples/lib/settings/scoped_settings.rb
D spec/support/examples/lib/settings/settings_extended.rb
M spec/support/examples/models/concerns/account_avatar.rb
M streaming/index.js
M .rubocop_todo.yml => .rubocop_todo.yml +8 -13
@@ 1,6 1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.50.2.
# using RuboCop version 1.52.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new


@@ 48,17 48,14 @@ Layout/SpaceInLambdaLiteral:
    - 'config/environments/production.rb'
    - 'config/initializers/content_security_policy.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedMethods, AllowedPatterns.
Lint/AmbiguousBlockAssociation:
  Exclude:
    - 'spec/controllers/admin/account_moderation_notes_controller_spec.rb'
    - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb'
    - 'spec/controllers/settings/two_factor_authentication/otp_authentication_controller_spec.rb'
    - 'spec/controllers/settings/two_factor_authentication/webauthn_credentials_controller_spec.rb'
    - 'spec/services/activitypub/process_status_update_service_spec.rb'
    - 'spec/services/post_status_service_spec.rb'
    - 'spec/services/suspend_account_service_spec.rb'
    - 'spec/services/unsuspend_account_service_spec.rb'
    - 'spec/workers/scheduler/accounts_statuses_cleanup_scheduler_spec.rb'

# Configuration parameters: AllowComments, AllowEmptyLambdas.


@@ 124,6 121,7 @@ Lint/UnusedBlockArgument:
    - 'config/initializers/paperclip.rb'
    - 'config/initializers/simple_form.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
Lint/UselessAssignment:
  Exclude:
    - 'app/services/activitypub/process_status_update_service.rb'


@@ 145,6 143,7 @@ Lint/UselessAssignment:
    - 'spec/services/resolve_url_service_spec.rb'
    - 'spec/views/statuses/show.html.haml_spec.rb'

# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: CheckForMethodsWithNoSideEffects.
Lint/Void:
  Exclude:


@@ 167,7 166,7 @@ Metrics/CyclomaticComplexity:

# Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
  Max: 28
  Max: 27

Naming/AccessorMethodName:
  Exclude:


@@ 180,6 179,7 @@ Naming/FileName:
  Exclude:
    - 'config/locales/sr-Latn.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyleForLeadingUnderscores.
# SupportedStylesForLeadingUnderscores: disallowed, required, optional
Naming/MemoizedInstanceVariableName:


@@ 322,8 322,6 @@ RSpec/LetSetup:
    - 'spec/controllers/admin/statuses_controller_spec.rb'
    - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb'
    - 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
    - 'spec/controllers/api/v1/admin/domain_allows_controller_spec.rb'
    - 'spec/controllers/api/v1/admin/domain_blocks_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'


@@ 396,7 394,7 @@ RSpec/MessageSpies:
    - 'spec/validators/status_length_validator_spec.rb'

RSpec/MultipleExpectations:
  Max: 19
  Max: 8

# Configuration parameters: AllowSubject.
RSpec/MultipleMemoizedHelpers:


@@ 424,7 422,6 @@ RSpec/StubbedMock:
RSpec/SubjectDeclaration:
  Exclude:
    - 'spec/controllers/admin/domain_blocks_controller_spec.rb'
    - 'spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb'
    - 'spec/models/account_migration_spec.rb'
    - 'spec/models/account_spec.rb'
    - 'spec/models/relationship_filter_spec.rb'


@@ 599,7 596,6 @@ Rails/NegateInclude:
    - 'app/models/concerns/attachmentable.rb'
    - 'app/models/concerns/remotable.rb'
    - 'app/models/custom_filter.rb'
    - 'app/models/webhook.rb'
    - 'app/services/activitypub/process_status_update_service.rb'
    - 'app/services/fetch_link_card_service.rb'
    - 'app/services/search_service.rb'


@@ 770,11 766,9 @@ 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/accounts/notes_controller_spec.rb'
    - 'spec/controllers/api/v1/tags_controller_spec.rb'
    - 'spec/models/account_spec.rb'
    - 'spec/services/activitypub/process_collection_service_spec.rb'
    - 'spec/services/post_status_service_spec.rb'
    - 'spec/services/purge_domain_service_spec.rb'
    - 'spec/services/unallow_domain_service_spec.rb'



@@ 796,6 790,7 @@ Style/ClassVars:
  Exclude:
    - 'config/initializers/devise.rb'

# This cop supports unsafe autocorrection (--autocorrect-all).
Style/CombinableLoops:
  Exclude:
    - 'app/models/form/custom_emoji_batch.rb'

M Gemfile.lock => Gemfile.lock +7 -6
@@ 470,8 470,9 @@ GEM
    orm_adapter (0.5.0)
    ox (2.14.16)
    parallel (1.23.0)
    parser (3.2.2.1)
    parser (3.2.2.3)
      ast (~> 2.4.1)
      racc
    parslet (2.0.0)
    pastel (0.8.0)
      tty-color (~> 0.5)


@@ 495,7 496,7 @@ GEM
    pundit (2.3.0)
      activesupport (>= 3.0.0)
    raabro (1.4.0)
    racc (1.6.2)
    racc (1.7.0)
    rack (2.2.7)
    rack-attack (6.6.1)
      rack (>= 1.0, < 3)


@@ 557,7 558,7 @@ GEM
      redis (>= 4)
    redlock (1.3.2)
      redis (>= 3.0.0, < 6.0)
    regexp_parser (2.8.0)
    regexp_parser (2.8.1)
    request_store (1.5.1)
      rack (>= 1.4)
    responders (3.1.0)


@@ 591,17 592,17 @@ GEM
      sidekiq (>= 2.4.0)
    rspec-support (3.12.0)
    rspec_chunked (0.6)
    rubocop (1.51.0)
    rubocop (1.52.1)
      json (~> 2.3)
      parallel (~> 1.10)
      parser (>= 3.2.0.0)
      parser (>= 3.2.2.3)
      rainbow (>= 2.2.2, < 4.0)
      regexp_parser (>= 1.8, < 3.0)
      rexml (>= 3.2.5, < 4.0)
      rubocop-ast (>= 1.28.0, < 2.0)
      ruby-progressbar (~> 1.7)
      unicode-display_width (>= 2.4.0, < 3.0)
    rubocop-ast (1.28.1)
    rubocop-ast (1.29.0)
      parser (>= 3.2.1.0)
    rubocop-capybara (2.18.0)
      rubocop (~> 1.41)

M app/controllers/concerns/captcha_concern.rb => app/controllers/concerns/captcha_concern.rb +5 -0
@@ 2,6 2,7 @@

module CaptchaConcern
  extend ActiveSupport::Concern

  include Hcaptcha::Adapters::ViewMethods

  included do


@@ 35,18 36,22 @@ module CaptchaConcern
        flash.delete(:hcaptcha_error)
        yield message
      end

      false
    end
  end

  def extend_csp_for_captcha!
    policy = request.content_security_policy

    return unless captcha_required? && policy.present?

    %w(script_src frame_src style_src connect_src).each do |directive|
      values = policy.send(directive)

      values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
      values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')

      policy.send(directive, *values)
    end
  end

A app/controllers/mail_subscriptions_controller.rb => app/controllers/mail_subscriptions_controller.rb +41 -0
@@ 0,0 1,41 @@
# frozen_string_literal: true

class MailSubscriptionsController < ApplicationController
  layout 'auth'

  skip_before_action :require_functional!

  before_action :set_body_classes
  before_action :set_user
  before_action :set_type

  def show; end

  def create
    @user.settings[email_type_from_param] = false
    @user.save!
  end

  private

  def set_user
    @user = GlobalID::Locator.locate_signed(params[:token], for: 'unsubscribe')
  end

  def set_body_classes
    @body_classes = 'lighter'
  end

  def set_type
    @type = email_type_from_param
  end

  def email_type_from_param
    case params[:type]
    when 'follow', 'reblog', 'favourite', 'mention', 'follow_request'
      "notification_emails.#{params[:type]}"
    else
      raise ArgumentError
    end
  end
end

A app/javascript/mastodon/components/circular_progress.tsx => app/javascript/mastodon/components/circular_progress.tsx +27 -0
@@ 0,0 1,27 @@
interface Props {
  size: number;
  strokeWidth: number;
}

export const CircularProgress: React.FC<Props> = ({ size, strokeWidth }) => {
  const viewBox = `0 0 ${size} ${size}`;
  const radius = (size - strokeWidth) / 2;

  return (
    <svg
      width={size}
      height={size}
      viewBox={viewBox}
      className='circular-progress'
      role='progressbar'
    >
      <circle
        fill='none'
        cx={size / 2}
        cy={size / 2}
        r={radius}
        strokeWidth={`${strokeWidth}px`}
      />
    </svg>
  );
};

M app/javascript/mastodon/components/dropdown_menu.jsx => app/javascript/mastodon/components/dropdown_menu.jsx +1 -2
@@ 8,8 8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay';

import { CircularProgress } from 'mastodon/components/loading_indicator';

import { CircularProgress } from "./circular_progress";
import { IconButton } from './icon_button';

const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;

D app/javascript/mastodon/components/load_pending.jsx => app/javascript/mastodon/components/load_pending.jsx +0 -23
@@ 1,23 0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';

import { FormattedMessage } from 'react-intl';

export default class LoadPending extends PureComponent {

  static propTypes = {
    onClick: PropTypes.func,
    count: PropTypes.number,
  };

  render() {
    const { count } = this.props;

    return (
      <button className='load-more load-gap' onClick={this.props.onClick}>
        <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} />
      </button>
    );
  }

}

A app/javascript/mastodon/components/load_pending.tsx => app/javascript/mastodon/components/load_pending.tsx +18 -0
@@ 0,0 1,18 @@
import { FormattedMessage } from 'react-intl';

interface Props {
  onClick: (event: React.MouseEvent) => void;
  count: number;
}

export const LoadPending: React.FC<Props> = ({ onClick, count }) => {
  return (
    <button className='load-more load-gap' onClick={onClick}>
      <FormattedMessage
        id='load_pending'
        defaultMessage='{count, plural, one {# new item} other {# new items}}'
        values={{ count }}
      />
    </button>
  );
};

D app/javascript/mastodon/components/loading_indicator.jsx => app/javascript/mastodon/components/loading_indicator.jsx +0 -31
@@ 1,31 0,0 @@
import PropTypes from 'prop-types';

export const CircularProgress = ({ size, strokeWidth }) => {
  const viewBox = `0 0 ${size} ${size}`;
  const radius  = (size - strokeWidth) / 2;

  return (
    <svg width={size} height={size} viewBox={viewBox} className='circular-progress' role='progressbar'>
      <circle
        fill='none'
        cx={size / 2}
        cy={size / 2}
        r={radius}
        strokeWidth={`${strokeWidth}px`}
      />
    </svg>
  );
};

CircularProgress.propTypes = {
  size: PropTypes.number.isRequired,
  strokeWidth: PropTypes.number.isRequired,
};

const LoadingIndicator = () => (
  <div className='loading-indicator'>
    <CircularProgress size={50} strokeWidth={6} />
  </div>
);

export default LoadingIndicator;

A app/javascript/mastodon/components/loading_indicator.tsx => app/javascript/mastodon/components/loading_indicator.tsx +7 -0
@@ 0,0 1,7 @@
import { CircularProgress } from './circular_progress';

export const LoadingIndicator: React.FC = () => (
  <div className='loading-indicator'>
    <CircularProgress size={50} strokeWidth={6} />
  </div>
);

M app/javascript/mastodon/components/scrollable_list.jsx => app/javascript/mastodon/components/scrollable_list.jsx +2 -2
@@ 16,8 16,8 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';

import { LoadMore } from './load_more';
import LoadPending from './load_pending';
import LoadingIndicator from './loading_indicator';
import { LoadPending } from './load_pending';
import { LoadingIndicator } from './loading_indicator';

const MOUSE_IDLE_DELAY = 300;


M app/javascript/mastodon/features/account/components/header.jsx => app/javascript/mastodon/features/account/components/header.jsx +1 -1
@@ 374,7 374,7 @@ class Header extends ImmutablePureComponent {
    let badge;

    if (account.get('bot')) {
      badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
      badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Automated' /></div>);
    } else if (account.get('group')) {
      badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
    } else {

M app/javascript/mastodon/features/account_gallery/index.jsx => app/javascript/mastodon/features/account_gallery/index.jsx +1 -1
@@ 10,7 10,7 @@ import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';

M app/javascript/mastodon/features/account_timeline/index.jsx => app/javascript/mastodon/features/account_timeline/index.jsx +1 -1
@@ 17,7 17,7 @@ import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
import ColumnBackButton from '../../components/column_back_button';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';


M app/javascript/mastodon/features/blocks/index.jsx => app/javascript/mastodon/features/blocks/index.jsx +1 -1
@@ 10,7 10,7 @@ import { debounce } from 'lodash';

import { fetchBlocks, expandBlocks } from '../../actions/blocks';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';

M app/javascript/mastodon/features/directory/index.jsx => app/javascript/mastodon/features/directory/index.jsx +1 -1
@@ 14,7 14,7 @@ import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import ScrollContainer from 'mastodon/containers/scroll_container';


M app/javascript/mastodon/features/domain_blocks/index.jsx => app/javascript/mastodon/features/domain_blocks/index.jsx +1 -1
@@ 12,7 12,7 @@ import { debounce } from 'lodash';

import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import DomainContainer from '../../containers/domain_container';
import Column from '../ui/components/column';

A app/javascript/mastodon/features/emoji/emoji_compressed.d.ts => app/javascript/mastodon/features/emoji/emoji_compressed.d.ts +51 -0
@@ 0,0 1,51 @@
import type { BaseEmoji, EmojiData, NimbleEmojiIndex } from 'emoji-mart';
import type { Category, Data, Emoji } from 'emoji-mart/dist-es/utils/data';

/*
 * The 'search' property, although not defined in the [`Emoji`]{@link node_modules/@types/emoji-mart/dist-es/utils/data.d.ts#Emoji} type,
 * is used in the application.
 * This could be due to an oversight by the library maintainer.
 * The `search` property is defined and used [here]{@link node_modules/emoji-mart/dist/utils/data.js#uncompress}.
 */
export type Search = string;
/*
 * The 'skins' property does not exist in the application data.
 * This could be a potential area of refactoring or error handling.
 * The non-existence of 'skins' property is evident at [this location]{@link app/javascript/mastodon/features/emoji/emoji_compressed.js:121}.
 */
export type Skins = null;

export type FilenameData = string[] | string[][];
export type ShortCodesToEmojiDataKey =
  | EmojiData['id']
  | BaseEmoji['native']
  | keyof NimbleEmojiIndex['emojis'];

export type SearchData = [
  BaseEmoji['native'],
  Emoji['short_names'],
  Search,
  Emoji['unified']
];

export interface ShortCodesToEmojiData {
  [key: ShortCodesToEmojiDataKey]: [FilenameData, SearchData];
}
export type EmojisWithoutShortCodes = FilenameData[];

export type EmojiCompressed = [
  ShortCodesToEmojiData,
  Skins,
  Category[],
  Data['aliases'],
  EmojisWithoutShortCodes
];

/*
 * `emoji_compressed.js` uses `babel-plugin-preval`, which makes it difficult to convert to TypeScript.
 * As a temporary solution, we are allowing a default export here to apply the TypeScript type `EmojiCompressed` to the JS file export.
 * - {@link app/javascript/mastodon/features/emoji/emoji_compressed.js}
 */
declare const emojiCompressed: EmojiCompressed;

export default emojiCompressed; // eslint-disable-line import/no-default-export

M app/javascript/mastodon/features/emoji/emoji_compressed.js => app/javascript/mastodon/features/emoji/emoji_compressed.js +10 -0
@@ 118,6 118,16 @@ Object.keys(emojiIndex.emojis).forEach(key => {
// inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([
  shortCodesToEmojiData,
  /*
   * The property `skins` is not found in the current context.
   * This could potentially lead to issues when interacting with modules or data structures
   * that expect the presence of `skins` property.
   * Currently, no definitions or references to `skins` property can be found in:
   * - {@link node_modules/emoji-mart/dist/utils/data.js}
   * - {@link node_modules/emoji-mart/data/all.json}
   * - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins}
   * Future refactorings or updates should consider adding definitions or handling for `skins` property.
   */
  emojiMartData.skins,
  emojiMartData.categories,
  emojiMartData.aliases,

R app/javascript/mastodon/features/emoji/emoji_mart_data_light.js => app/javascript/mastodon/features/emoji/emoji_mart_data_light.ts +28 -19
@@ 1,32 1,46 @@
// The output of this module is designed to mimic emoji-mart's
// "data" object, such that we can use it for a light version of emoji-mart's
// emojiIndex.search functionality.
import type { BaseEmoji } from 'emoji-mart';
import type { Emoji } from 'emoji-mart/dist-es/utils/data';

import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';

const [ shortCodesToEmojiData, skins, categories, short_names ] = emojiCompressed;
type Emojis = {
  [key in keyof ShortCodesToEmojiData]: {
    native: BaseEmoji['native'];
    search: Search;
    short_names: Emoji['short_names'];
    unified: Emoji['unified'];
  };
};

const [
  shortCodesToEmojiData,
  skins,
  categories,
  short_names,
  _emojisWithoutShortCodes,
] = emojiCompressed;

const emojis = {};
const emojis: Emojis = {};

// decompress
Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
  let [
    filenameData, // eslint-disable-line @typescript-eslint/no-unused-vars
    searchData,
  ] = shortCodesToEmojiData[shortCode];
  let [
    native,
    short_names,
    search,
    unified,
  ] = searchData;
  const [_filenameData, searchData] = shortCodesToEmojiData[shortCode];
  const native = searchData[0];
  let short_names = searchData[1];
  const search = searchData[2];
  let unified = searchData[3];

  if (!unified) {
    // unified name can be derived from unicodeToUnifiedName
    unified = unicodeToUnifiedName(native);
  }

  short_names = [shortCode].concat(short_names);
  if (short_names) short_names = [shortCode].concat(short_names);
  emojis[shortCode] = {
    native,
    search,


@@ 35,9 49,4 @@ Object.keys(shortCodesToEmojiData).forEach((shortCode) => {
  };
});

export {
  emojis,
  skins,
  categories,
  short_names,
};
export { emojis, skins, categories, short_names };

M app/javascript/mastodon/features/explore/links.jsx => app/javascript/mastodon/features/explore/links.jsx +1 -1
@@ 8,7 8,7 @@ import { connect } from 'react-redux';

import { fetchTrendingLinks } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';

import Story from './components/story';


M app/javascript/mastodon/features/explore/results.jsx => app/javascript/mastodon/features/explore/results.jsx +1 -1
@@ 12,7 12,7 @@ import { connect } from 'react-redux';
import { expandSearch } from 'mastodon/actions/search';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import { LoadMore } from 'mastodon/components/load_more';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import Account from 'mastodon/containers/account_container';
import Status from 'mastodon/containers/status_container';


M app/javascript/mastodon/features/explore/suggestions.jsx => app/javascript/mastodon/features/explore/suggestions.jsx +1 -1
@@ 7,7 7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';

import { fetchSuggestions } from 'mastodon/actions/suggestions';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import AccountCard from 'mastodon/features/directory/components/account_card';

const mapStateToProps = state => ({

M app/javascript/mastodon/features/explore/tags.jsx => app/javascript/mastodon/features/explore/tags.jsx +1 -1
@@ 9,7 9,7 @@ import { connect } from 'react-redux';
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';

const mapStateToProps = state => ({
  hashtags: state.getIn(['trends', 'tags', 'items']),

M app/javascript/mastodon/features/favourites/index.jsx => app/javascript/mastodon/features/favourites/index.jsx +1 -1
@@ 11,7 11,7 @@ import { connect } from 'react-redux';
import { fetchFavourites } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon }  from 'mastodon/components/icon';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import AccountContainer from 'mastodon/containers/account_container';
import Column from 'mastodon/features/ui/components/column';

M app/javascript/mastodon/features/followers/index.jsx => app/javascript/mastodon/features/followers/index.jsx +1 -1
@@ 20,7 20,7 @@ import {
  expandFollowers,
} from '../../actions/accounts';
import ColumnBackButton from '../../components/column_back_button';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';

M app/javascript/mastodon/features/following/index.jsx => app/javascript/mastodon/features/following/index.jsx +1 -1
@@ 20,7 20,7 @@ import {
  expandFollowing,
} from '../../actions/accounts';
import ColumnBackButton from '../../components/column_back_button';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import LimitedAccountHint from '../account_timeline/components/limited_account_hint';

M app/javascript/mastodon/features/list_timeline/index.jsx => app/javascript/mastodon/features/list_timeline/index.jsx +1 -1
@@ 18,7 18,7 @@ import { expandListTimeline } from 'mastodon/actions/timelines';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon }  from 'mastodon/components/icon';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';

M app/javascript/mastodon/features/lists/index.jsx => app/javascript/mastodon/features/lists/index.jsx +1 -1
@@ 12,7 12,7 @@ import { createSelector } from 'reselect';
import { fetchLists } from 'mastodon/actions/lists';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list';
import ColumnLink from 'mastodon/features/ui/components/column_link';
import ColumnSubheading from 'mastodon/features/ui/components/column_subheading';

M app/javascript/mastodon/features/mutes/index.jsx => app/javascript/mastodon/features/mutes/index.jsx +1 -1
@@ 12,7 12,7 @@ import { debounce } from 'lodash';

import { fetchMutes, expandMutes } from '../../actions/mutes';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';

M app/javascript/mastodon/features/notifications/components/report.jsx => app/javascript/mastodon/features/notifications/components/report.jsx +2 -0
@@ 8,10 8,12 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { AvatarOverlay } from 'mastodon/components/avatar_overlay';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';

// This needs to be kept in sync with app/models/report.rb
const messages = defineMessages({
  openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
  other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
  spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
  legal: { id: 'report_notification.categories.legal', defaultMessage: 'Legal' },
  violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
});


M app/javascript/mastodon/features/reblogs/index.jsx => app/javascript/mastodon/features/reblogs/index.jsx +1 -1
@@ 12,7 12,7 @@ import { Icon }  from 'mastodon/components/icon';

import { fetchReblogs } from '../../actions/interactions';
import ColumnHeader from '../../components/column_header';
import LoadingIndicator from '../../components/loading_indicator';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
import Column from '../ui/components/column';

M app/javascript/mastodon/features/report/statuses.jsx => app/javascript/mastodon/features/report/statuses.jsx +1 -1
@@ 8,7 8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';

import Button from 'mastodon/components/button';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusCheckBox from 'mastodon/features/report/containers/status_check_box_container';

const mapStateToProps = (state, { accountId }) => ({

M app/javascript/mastodon/features/status/index.jsx => app/javascript/mastodon/features/status/index.jsx +1 -1
@@ 14,7 14,7 @@ import { createSelector } from 'reselect';
import { HotKeys } from 'react-hotkeys';

import { Icon }  from 'mastodon/components/icon';
import LoadingIndicator from 'mastodon/components/loading_indicator';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';


M app/javascript/mastodon/features/ui/components/modal_loading.jsx => app/javascript/mastodon/features/ui/components/modal_loading.jsx +1 -1
@@ 1,4 1,4 @@
import LoadingIndicator from '../../../components/loading_indicator';
import { LoadingIndicator } from '../../../components/loading_indicator';

// Keep the markup in sync with <BundleModalError />
// (make sure they have the same dimensions)

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +2 -1
@@ 13,7 13,7 @@
  "about.rules": "Server rules",
  "account.account_note_header": "Note",
  "account.add_or_remove_from_list": "Add or Remove from lists",
  "account.badges.bot": "Bot",
  "account.badges.bot": "Automated",
  "account.badges.group": "Group",
  "account.block": "Block @{name}",
  "account.block_domain": "Block domain {domain}",


@@ 553,6 553,7 @@
  "report.unfollow": "Unfollow @{name}",
  "report.unfollow_explanation": "You are following this account. To not see their posts in your home feed anymore, unfollow them.",
  "report_notification.attached_statuses": "{count, plural, one {{count} post} other {{count} posts}} attached",
  "report_notification.categories.legal": "Legal",
  "report_notification.categories.other": "Other",
  "report_notification.categories.spam": "Spam",
  "report_notification.categories.violation": "Rule violation",

M app/javascript/styles/mastodon/forms.scss => app/javascript/styles/mastodon/forms.scss +3 -1
@@ 1048,7 1048,9 @@ code {
}

.simple_form .h-captcha {
  text-align: center;
  display: flex;
  justify-content: center;
  margin-bottom: 30px;
}

.permissions-list {

M app/mailers/notification_mailer.rb => app/mailers/notification_mailer.rb +20 -10
@@ 8,61 8,71 @@ class NotificationMailer < ApplicationMailer

  def mention(recipient, notification)
    @me     = recipient
    @user   = recipient.user
    @type   = 'mention'
    @status = notification.target_status

    return unless @me.user.functional? && @status.present?
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
    end
  end

  def follow(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'follow'
    @account = notification.from_account

    return unless @me.user.functional?
    return unless @user.functional?

    locale_for_account(@me) do
      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
    end
  end

  def favourite(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'favourite'
    @account = notification.from_account
    @status  = notification.target_status

    return unless @me.user.functional? && @status.present?
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
    end
  end

  def reblog(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'reblog'
    @account = notification.from_account
    @status  = notification.target_status

    return unless @me.user.functional? && @status.present?
    return unless @user.functional? && @status.present?

    locale_for_account(@me) do
      thread_by_conversation(@status.conversation)
      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
    end
  end

  def follow_request(recipient, notification)
    @me      = recipient
    @user    = recipient.user
    @type    = 'follow_request'
    @account = notification.from_account

    return unless @me.user.functional?
    return unless @user.functional?

    locale_for_account(@me) do
      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
      mail to: email_address_with_name(@user.email, @me.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
    end
  end


M app/models/report.rb => app/models/report.rb +1 -0
@@ 48,6 48,7 @@ class Report < ApplicationRecord

  validate :validate_rule_ids

  # entries here needs to be kept in sync with app/javascript/mastodon/features/notifications/components/report.jsx
  enum category: {
    other: 0,
    spam: 1_000,

M app/views/auth/confirmations/captcha.html.haml => app/views/auth/confirmations/captcha.html.haml +5 -3
@@ 7,10 7,12 @@
  = hidden_field_tag :confirmation_token, params[:confirmation_token]
  = hidden_field_tag :redirect_to_app, params[:redirect_to_app]

  %h1.title= t('auth.captcha_confirmation.title')
  %p.lead= t('auth.captcha_confirmation.hint_html')

  .field-group
    = render_captcha
  = render_captcha

  %p.lead= t('auth.captcha_confirmation.help_html', email: mail_to(Setting.site_contact_email, nil))

  .actions
    %button.button= t('challenge.confirm')
    = button_tag t('challenge.confirm'), class: 'button', type: :submit

M app/views/layouts/mailer.html.haml => app/views/layouts/mailer.html.haml +5 -1
@@ 44,7 44,11 @@
                            %tbody
                              %td.column-cell
                                %p= t 'about.hosted_on', domain: site_hostname
                                %p= link_to t('application_mailer.notification_preferences'), settings_preferences_notifications_url
                                %p
                                  = link_to t('application_mailer.notification_preferences'), settings_preferences_notifications_url
                                  - if defined?(@type)
                                    ·
                                    = link_to t('application_mailer.unsubscribe'), unsubscribe_url(token: @user.to_sgid(for: 'unsubscribe').to_s, type: @type)
                              %td.column-cell.text-right
                                = link_to root_url do
                                  = image_tag full_pack_url('media/images/mailer/logo.png'), alt: 'Mastodon', height: 24

A app/views/mail_subscriptions/create.html.haml => app/views/mail_subscriptions/create.html.haml +9 -0
@@ 0,0 1,9 @@
- content_for :page_title do
  = t('mail_subscriptions.unsubscribe.title')

.simple_form
  %h1.title= t('mail_subscriptions.unsubscribe.complete')
  %p.lead
    = t('mail_subscriptions.unsubscribe.success_html', domain: content_tag(:strong, site_hostname), type: content_tag(:strong, I18n.t(@type, scope: 'mail_subscriptions.unsubscribe.emails')), email: content_tag(:strong, @user.email))
  %p.lead
    = t('mail_subscriptions.unsubscribe.resubscribe_html', settings_path: settings_preferences_notifications_path)

A app/views/mail_subscriptions/show.html.haml => app/views/mail_subscriptions/show.html.haml +12 -0
@@ 0,0 1,12 @@
- content_for :page_title do
  = t('mail_subscriptions.unsubscribe.title')

.simple_form
  %h1.title= t('mail_subscriptions.unsubscribe.title')
  %p.lead
    = t('mail_subscriptions.unsubscribe.confirmation_html', domain: content_tag(:strong, site_hostname), type: content_tag(:strong, I18n.t(@type, scope: 'mail_subscriptions.unsubscribe.emails')), email: content_tag(:strong, @user.email), settings_path: settings_preferences_notifications_path)

  = form_tag unsubscribe_path, method: :post do
    = hidden_field_tag :token, params[:token]
    = hidden_field_tag :type, params[:type]
    = button_tag t('mail_subscriptions.unsubscribe.action'), type: :submit

M config/i18n-tasks.yml => config/i18n-tasks.yml +1 -0
@@ 74,6 74,7 @@ ignore_unused:
  - 'notification_mailer.*'
  - 'imports.overwrite_preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
  - 'imports.preambles.{following,blocking,muting,domain_blocking,bookmarks}_html'
  - 'mail_subscriptions.unsubscribe.emails.*'

ignore_inconsistent_interpolations:
  - '*.one'

M config/locales/en.yml => config/locales/en.yml +19 -2
@@ 978,6 978,7 @@ en:
    notification_preferences: Change e-mail preferences
    salutation: "%{name},"
    settings: 'Change e-mail preferences: %{link}'
    unsubscribe: Unsubscribe
    view: 'View:'
    view_profile: View profile
    view_status: View post


@@ 992,8 993,9 @@ en:
  auth:
    apply_for_account: Request an account
    captcha_confirmation:
      hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can <a href="/about/more">contact the server administrator</a> if you have questions or need assistance with confirming your account.
      title: User verification
      help_html: If you have issues solving the CAPTCHA, you can get in touch with us through %{email} and we can assist you.
      hint_html: Just one more thing! We need to confirm you're a human (this is so we can keep the spam out!). Solve the CAPTCHA below and click "Continue".
      title: Security check
    change_password: Password
    confirmations:
      wrong_email_hint: If that e-mail address is not correct, you can change it in account settings.


@@ 1342,6 1344,21 @@ en:
    failed_sign_in_html: Failed sign-in attempt with %{method} from %{ip} (%{browser})
    successful_sign_in_html: Successful sign-in with %{method} from %{ip} (%{browser})
    title: Authentication history
  mail_subscriptions:
    unsubscribe:
      action: Yes, unsubscribe
      complete: Unsubscribed
      confirmation_html: Are you sure you want to unsubscribe from receiving %{type} for Mastodon on %{domain} to your e-mail at %{email}? You can always re-subscribe from your <a href="%{settings_path}">e-mail notification settings</a>.
      emails:
        notification_emails:
          favourite: favorite notification e-mails
          follow: follow notification e-mails
          follow_request: follow request e-mails
          mention: mention notification e-mails
          reblog: boost notification e-mails
      resubscribe_html: If you've unsubscribed by mistake, you can re-subscribe from your <a href="%{settings_path}">e-mail notification settings</a>.
      success_html: You'll no longer receive %{type} for Mastodon on %{domain} to your e-mail at %{email}.
      title: Unsubscribe
  media_attachments:
    validations:
      images_and_video: Cannot attach a video to a post that already contains images

M config/locales/simple_form.en.yml => config/locales/simple_form.en.yml +1 -1
@@ 168,7 168,7 @@ en:
      defaults:
        autofollow: Invite to follow your account
        avatar: Avatar
        bot: This is a bot account
        bot: This is an automated account
        chosen_languages: Filter languages
        confirm_new_password: Confirm new password
        confirm_password: Confirm password

M config/routes.rb => config/routes.rb +2 -0
@@ 68,6 68,8 @@ Rails.application.routes.draw do
  devise_scope :user do
    get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite

    resource :unsubscribe, only: [:show, :create], controller: :mail_subscriptions

    namespace :auth do
      resource :setup, only: [:show, :update], controller: :setup
      resource :challenge, only: [:create], controller: :challenges

M lib/mastodon/cli/cache.rb => lib/mastodon/cli/cache.rb +34 -14
@@ 23,22 23,12 @@ module Mastodon::CLI
    def recount(type)
      case type
      when 'accounts'
        processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account|
          account_stat                 = account.account_stat
          account_stat.following_count = account.active_relationships.count
          account_stat.followers_count = account.passive_relationships.count
          account_stat.statuses_count  = account.statuses.where.not(visibility: :direct).count

          account_stat.save if account_stat.changed?
        processed, = parallelize_with_progress(accounts_with_stats) do |account|
          recount_account_stats(account)
        end
      when 'statuses'
        processed, = parallelize_with_progress(Status.includes(:status_stat)) do |status|
          status_stat                  = status.status_stat
          status_stat.replies_count    = status.replies.where.not(visibility: :direct).count
          status_stat.reblogs_count    = status.reblogs.count
          status_stat.favourites_count = status.favourites.count

          status_stat.save if status_stat.changed?
        processed, = parallelize_with_progress(statuses_with_stats) do |status|
          recount_status_stats(status)
        end
      else
        say("Unknown type: #{type}", :red)


@@ 48,5 38,35 @@ module Mastodon::CLI
      say
      say("OK, recounted #{processed} records", :green)
    end

    private

    def accounts_with_stats
      Account.local.includes(:account_stat)
    end

    def statuses_with_stats
      Status.includes(:status_stat)
    end

    def recount_account_stats(account)
      account.account_stat.tap do |account_stat|
        account_stat.following_count = account.active_relationships.count
        account_stat.followers_count = account.passive_relationships.count
        account_stat.statuses_count  = account.statuses.where.not(visibility: :direct).count

        account_stat.save if account_stat.changed?
      end
    end

    def recount_status_stats(status)
      status.status_stat.tap do |status_stat|
        status_stat.replies_count    = status.replies.where.not(visibility: :direct).count
        status_stat.reblogs_count    = status.reblogs.count
        status_stat.favourites_count = status.favourites.count

        status_stat.save if status_stat.changed?
      end
    end
  end
end

M lib/mastodon/cli/feeds.rb => lib/mastodon/cli/feeds.rb +7 -1
@@ 19,7 19,7 @@ module Mastodon::CLI
    LONG_DESC
    def build(username = nil)
      if options[:all] || username.nil?
        processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account|
        processed, = parallelize_with_progress(active_user_accounts) do |account|
          PrecomputeFeedService.new.call(account) unless dry_run?
        end



@@ 47,5 47,11 @@ module Mastodon::CLI
      redis.del(keys)
      say('OK', :green)
    end

    private

    def active_user_accounts
      Account.joins(:user).merge(User.active)
    end
  end
end

M spec/controllers/api/v1/accounts/relationships_controller_spec.rb => spec/controllers/api/v1/accounts/relationships_controller_spec.rb +26 -19
@@ 48,25 48,32 @@ describe Api::V1::Accounts::RelationshipsController do
        expect(response).to have_http_status(200)
      end

      it 'returns JSON with correct data' do
        json = body_as_json

        expect(json).to be_a Enumerable
        expect(json.first[:id]).to eq simon.id.to_s
        expect(json.first[:following]).to be true
        expect(json.first[:showing_reblogs]).to be true
        expect(json.first[:followed_by]).to be false
        expect(json.first[:muting]).to be false
        expect(json.first[:requested]).to be false
        expect(json.first[:domain_blocking]).to be false

        expect(json.second[:id]).to eq lewis.id.to_s
        expect(json.second[:following]).to be false
        expect(json.second[:showing_reblogs]).to be false
        expect(json.second[:followed_by]).to be true
        expect(json.second[:muting]).to be false
        expect(json.second[:requested]).to be false
        expect(json.second[:domain_blocking]).to be false
      context 'when there is returned JSON data' do
        let(:json) { body_as_json }

        it 'returns an enumerable json' do
          expect(json).to be_a Enumerable
        end

        it 'returns a correct first element' do
          expect(json.first[:id]).to eq simon.id.to_s
          expect(json.first[:following]).to be true
          expect(json.first[:showing_reblogs]).to be true
          expect(json.first[:followed_by]).to be false
          expect(json.first[:muting]).to be false
          expect(json.first[:requested]).to be false
          expect(json.first[:domain_blocking]).to be false
        end

        it 'returns a correct second element' do
          expect(json.second[:id]).to eq lewis.id.to_s
          expect(json.second[:following]).to be false
          expect(json.second[:showing_reblogs]).to be false
          expect(json.second[:followed_by]).to be true
          expect(json.second[:muting]).to be false
          expect(json.second[:requested]).to be false
          expect(json.second[:domain_blocking]).to be false
        end
      end

      it 'returns JSON with correct data on cached requests too' do

M spec/controllers/api/v1/accounts_controller_spec.rb => spec/controllers/api/v1/accounts_controller_spec.rb +0 -14
@@ 55,20 55,6 @@ RSpec.describe Api::V1::AccountsController do
    end
  end

  describe 'GET #show' do
    let(:scopes) { 'read:accounts' }

    before do
      get :show, params: { id: user.account.id }
    end

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
  end

  describe 'POST #follow' do
    let(:scopes) { 'write:follows' }
    let(:other_account) { Fabricate(:account, username: 'bob', locked: locked) }

D spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb => spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb +0 -180
@@ 1,180 0,0 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Api::V1::Admin::DomainBlocksController do
  render_views

  let(:role)   { UserRole.find_by(name: 'Admin') }
  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) }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      expect(response).to have_http_status(403)
    end
  end

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #index' do
    let!(:block) { Fabricate(:domain_block) }

    before do
      get :index
    end

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end

    it 'returns the expected domain blocks' do
      json = body_as_json
      expect(json.length).to eq 1
      expect(json[0][:id].to_i).to eq block.id
    end
  end

  describe 'GET #show' do
    let!(:block) { Fabricate(:domain_block) }

    before do
      get :show, params: { id: block.id }
    end

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end

    it 'returns expected domain name' do
      json = body_as_json
      expect(json[:domain]).to eq block.domain
    end
  end

  describe 'PUT #update' do
    let!(:remote_account) { Fabricate(:account, domain: 'example.com') }
    let(:subject) do
      post :update, params: { id: domain_block.id, domain: 'example.com', severity: new_severity }
    end
    let(:domain_block) { Fabricate(:domain_block, domain: 'example.com', severity: original_severity) }

    before do
      BlockDomainService.new.call(domain_block)
    end

    context 'when downgrading a domain suspension to silence' do
      let(:original_severity) { 'suspend' }
      let(:new_severity)      { 'silence' }

      it 'changes the block severity' do
        expect { subject }.to change { domain_block.reload.severity }.from('suspend').to('silence')
      end

      it 'undoes individual suspensions' do
        expect { subject }.to change { remote_account.reload.suspended? }.from(true).to(false)
      end

      it 'performs individual silences' do
        expect { subject }.to change { remote_account.reload.silenced? }.from(false).to(true)
      end
    end

    context 'when upgrading a domain silence to suspend' do
      let(:original_severity) { 'silence' }
      let(:new_severity)      { 'suspend' }

      it 'changes the block severity' do
        expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
      end

      it 'undoes individual silences' do
        expect { subject }.to change { remote_account.reload.silenced? }.from(true).to(false)
      end

      it 'performs individual suspends' do
        expect { subject }.to change { remote_account.reload.suspended? }.from(false).to(true)
      end
    end
  end

  describe 'DELETE #destroy' do
    let!(:block) { Fabricate(:domain_block) }

    before do
      delete :destroy, params: { id: block.id }
    end

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end

    it 'deletes the block' do
      expect(DomainBlock.find_by(id: block.id)).to be_nil
    end
  end

  describe 'POST #create' do
    let(:existing_block_domain) { 'example.com' }
    let!(:block) { Fabricate(:domain_block, domain: existing_block_domain, severity: :suspend) }

    before do
      post :create, params: { domain: 'foo.bar.com', severity: :silence }
    end

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end

    it 'returns expected domain name' do
      json = body_as_json
      expect(json[:domain]).to eq 'foo.bar.com'
    end

    it 'creates a domain block' do
      expect(DomainBlock.find_by(domain: 'foo.bar.com')).to_not be_nil
    end

    context 'when a stricter domain block already exists' do
      let(:existing_block_domain) { 'bar.com' }

      it 'returns http unprocessable entity' do
        expect(response).to have_http_status(422)
      end

      it 'renders existing domain block in error' do
        json = body_as_json
        expect(json[:existing_domain_block][:domain]).to eq existing_block_domain
      end
    end
  end
end

D spec/controllers/api/v1/admin/reports_controller_spec.rb => spec/controllers/api/v1/admin/reports_controller_spec.rb +0 -111
@@ 1,111 0,0 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Api::V1::Admin::ReportsController 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(:report) { Fabricate(:report) }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      expect(response).to have_http_status(403)
    end
  end

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #index' do
    before do
      get :index
    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
  end

  describe 'GET #show' do
    before do
      get :show, params: { id: report.id }
    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
  end

  describe 'POST #resolve' do
    before do
      post :resolve, params: { id: report.id }
    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
  end

  describe 'POST #reopen' do
    before do
      post :reopen, params: { id: report.id }
    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
  end

  describe 'POST #assign_to_self' do
    before do
      post :assign_to_self, params: { id: report.id }
    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
  end

  describe 'POST #unassign' do
    before do
      post :unassign, params: { id: report.id }
    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
  end
end

M spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb => spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb +20 -11
@@ 56,18 56,11 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
        end

        describe 'when creation succeeds' do
          let!(:otp_backup_codes) { user.generate_otp_backup_codes! }

          it 'renders page with success' do
            otp_backup_codes = user.generate_otp_backup_codes!
            expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value|
              expect(value).to eq user
              otp_backup_codes
            end
            expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
              expect(value).to eq user
              expect(code).to eq '123456'
              expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
              true
            end
            prepare_user_otp_generation
            prepare_user_otp_consumption

            expect do
              post :create,


@@ 80,6 73,22 @@ describe Settings::TwoFactorAuthentication::ConfirmationsController do
            expect(response).to have_http_status(200)
            expect(response).to render_template('settings/two_factor_authentication/recovery_codes/index')
          end

          def prepare_user_otp_generation
            expect_any_instance_of(User).to receive(:generate_otp_backup_codes!) do |value|
              expect(value).to eq user
              otp_backup_codes
            end
          end

          def prepare_user_otp_consumption
            expect_any_instance_of(User).to receive(:validate_and_consume_otp!) do |value, code, options|
              expect(value).to eq user
              expect(code).to eq '123456'
              expect(options).to eq({ otp_secret: 'thisisasecretforthespecofnewview' })
              true
            end
          end
        end

        describe 'when creation fails' do

M spec/fabricators/domain_allow_fabricator.rb => spec/fabricators/domain_allow_fabricator.rb +1 -1
@@ 1,5 1,5 @@
# frozen_string_literal: true

Fabricator(:domain_allow) do
  domain 'MyString'
  domain { sequence(:domain) { |i| "example#{i}.com" } }
end

A spec/fabricators/status_stat_fabricator.rb => spec/fabricators/status_stat_fabricator.rb +8 -0
@@ 0,0 1,8 @@
# frozen_string_literal: true

Fabricator(:status_stat) do
  status
  replies_count '123'
  reblogs_count '456'
  favourites_count '789'
end

M spec/lib/mastodon/cli/cache_spec.rb => spec/lib/mastodon/cli/cache_spec.rb +59 -0
@@ 4,9 4,68 @@ require 'rails_helper'
require 'mastodon/cli/cache'

describe Mastodon::CLI::Cache do
  let(:cli) { described_class.new }

  describe '.exit_on_failure?' do
    it 'returns true' do
      expect(described_class.exit_on_failure?).to be true
    end
  end

  describe '#clear' do
    before { allow(Rails.cache).to receive(:clear) }

    it 'clears the Rails cache' do
      expect { cli.invoke(:clear) }.to output(
        a_string_including('OK')
      ).to_stdout
      expect(Rails.cache).to have_received(:clear)
    end
  end

  describe '#recount' do
    context 'with the `accounts` argument' do
      let(:arguments) { ['accounts'] }
      let(:account_stat) { Fabricate(:account_stat) }

      before do
        account_stat.update(statuses_count: 123)
      end

      it 're-calculates account records in the cache' do
        expect { cli.invoke(:recount, arguments) }.to output(
          a_string_including('OK')
        ).to_stdout

        expect(account_stat.reload.statuses_count).to be_zero
      end
    end

    context 'with the `statuses` argument' do
      let(:arguments) { ['statuses'] }
      let(:status_stat) { Fabricate(:status_stat) }

      before do
        status_stat.update(replies_count: 123)
      end

      it 're-calculates account records in the cache' do
        expect { cli.invoke(:recount, arguments) }.to output(
          a_string_including('OK')
        ).to_stdout

        expect(status_stat.reload.replies_count).to be_zero
      end
    end

    context 'with an unknown type' do
      let(:arguments) { ['other-type'] }

      it 'Exits with an error message' do
        expect { cli.invoke(:recount, arguments) }.to output(
          a_string_including('Unknown')
        ).to_stdout.and raise_error(SystemExit)
      end
    end
  end
end

M spec/lib/mastodon/cli/feeds_spec.rb => spec/lib/mastodon/cli/feeds_spec.rb +56 -0
@@ 4,9 4,65 @@ require 'rails_helper'
require 'mastodon/cli/feeds'

describe Mastodon::CLI::Feeds do
  let(:cli) { described_class.new }

  describe '.exit_on_failure?' do
    it 'returns true' do
      expect(described_class.exit_on_failure?).to be true
    end
  end

  describe '#build' do
    before { Fabricate(:account) }

    context 'with --all option' do
      let(:options) { { all: true } }

      it 'regenerates feeds for all accounts' do
        expect { cli.invoke(:build, [], options) }.to output(
          a_string_including('Regenerated feeds')
        ).to_stdout
      end
    end

    context 'with a username' do
      before { Fabricate(:account, username: 'alice') }

      let(:arguments) { ['alice'] }

      it 'regenerates feeds for the account' do
        expect { cli.invoke(:build, arguments) }.to output(
          a_string_including('OK')
        ).to_stdout
      end
    end

    context 'with invalid username' do
      let(:arguments) { ['invalid-username'] }

      it 'displays an error and exits' do
        expect { cli.invoke(:build, arguments) }.to output(
          a_string_including('No such account')
        ).to_stdout.and raise_error(SystemExit)
      end
    end
  end

  describe '#clear' do
    before do
      allow(redis).to receive(:del).with(key_namespace)
    end

    it 'clears the redis `feed:*` namespace' do
      expect { cli.invoke(:clear) }.to output(
        a_string_including('OK')
      ).to_stdout

      expect(redis).to have_received(:del).with(key_namespace).once
    end

    def key_namespace
      redis.keys('feed:*')
    end
  end
end

M spec/models/form/import_spec.rb => spec/models/form/import_spec.rb +38 -11
@@ 245,17 245,44 @@ RSpec.describe Form::Import do
        expect(account.bulk_imports.first.rows.pluck(:data)).to match_array(expected_rows)
      end

      it 'creates a BulkImport with expected attributes' do
        bulk_import = account.bulk_imports.first
        expect(bulk_import).to_not be_nil
        expect(bulk_import.type.to_sym).to eq subject.type.to_sym
        expect(bulk_import.original_filename).to eq subject.data.original_filename
        expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched?
        expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation
        expect(bulk_import.processed_items).to eq 0
        expect(bulk_import.imported_items).to eq 0
        expect(bulk_import.total_items).to eq bulk_import.rows.count
        expect(bulk_import.unconfirmed?).to be true
      context 'with a BulkImport' do
        let(:bulk_import) { account.bulk_imports.first }

        it 'creates a non-nil bulk import' do
          expect(bulk_import).to_not be_nil
        end

        it 'matches the subjects type' do
          expect(bulk_import.type.to_sym).to eq subject.type.to_sym
        end

        it 'matches the subjects original filename' do
          expect(bulk_import.original_filename).to eq subject.data.original_filename
        end

        it 'matches the subjects likely_mismatched? value' do
          expect(bulk_import.likely_mismatched?).to eq subject.likely_mismatched?
        end

        it 'matches the subject overwrite value' do
          expect(bulk_import.overwrite?).to eq !!subject.overwrite # rubocop:disable Style/DoubleNegation
        end

        it 'has zero processed items' do
          expect(bulk_import.processed_items).to eq 0
        end

        it 'has zero imported items' do
          expect(bulk_import.imported_items).to eq 0
        end

        it 'has a correct total_items value' do
          expect(bulk_import.total_items).to eq bulk_import.rows.count
        end

        it 'defaults to unconfirmed true' do
          expect(bulk_import.unconfirmed?).to be true
        end
      end
    end


M spec/models/notification_spec.rb => spec/models/notification_spec.rb +79 -65
@@ 99,73 99,87 @@ RSpec.describe Notification do
        ]
      end

      it 'preloads target status' do
        # mention
        expect(subject[0].type).to eq :mention
        expect(subject[0].association(:mention)).to be_loaded
        expect(subject[0].mention.association(:status)).to be_loaded

        # status
        expect(subject[1].type).to eq :status
        expect(subject[1].association(:status)).to be_loaded

        # reblog
        expect(subject[2].type).to eq :reblog
        expect(subject[2].association(:status)).to be_loaded
        expect(subject[2].status.association(:reblog)).to be_loaded

        # follow: nothing
        expect(subject[3].type).to eq :follow
        expect(subject[3].target_status).to be_nil

        # follow_request: nothing
        expect(subject[4].type).to eq :follow_request
        expect(subject[4].target_status).to be_nil

        # favourite
        expect(subject[5].type).to eq :favourite
        expect(subject[5].association(:favourite)).to be_loaded
        expect(subject[5].favourite.association(:status)).to be_loaded

        # poll
        expect(subject[6].type).to eq :poll
        expect(subject[6].association(:poll)).to be_loaded
        expect(subject[6].poll.association(:status)).to be_loaded
      context 'with a preloaded target status' do
        it 'preloads mention' do
          expect(subject[0].type).to eq :mention
          expect(subject[0].association(:mention)).to be_loaded
          expect(subject[0].mention.association(:status)).to be_loaded
        end

        it 'preloads status' do
          expect(subject[1].type).to eq :status
          expect(subject[1].association(:status)).to be_loaded
        end

        it 'preloads reblog' do
          expect(subject[2].type).to eq :reblog
          expect(subject[2].association(:status)).to be_loaded
          expect(subject[2].status.association(:reblog)).to be_loaded
        end

        it 'preloads follow as nil' do
          expect(subject[3].type).to eq :follow
          expect(subject[3].target_status).to be_nil
        end

        it 'preloads follow_request as nill' do
          expect(subject[4].type).to eq :follow_request
          expect(subject[4].target_status).to be_nil
        end

        it 'preloads favourite' do
          expect(subject[5].type).to eq :favourite
          expect(subject[5].association(:favourite)).to be_loaded
          expect(subject[5].favourite.association(:status)).to be_loaded
        end

        it 'preloads poll' do
          expect(subject[6].type).to eq :poll
          expect(subject[6].association(:poll)).to be_loaded
          expect(subject[6].poll.association(:status)).to be_loaded
        end
      end

      it 'replaces to cached status' do
        # mention
        expect(subject[0].type).to eq :mention
        expect(subject[0].target_status.association(:account)).to be_loaded
        expect(subject[0].target_status).to eq mention.status

        # status
        expect(subject[1].type).to eq :status
        expect(subject[1].target_status.association(:account)).to be_loaded
        expect(subject[1].target_status).to eq status

        # reblog
        expect(subject[2].type).to eq :reblog
        expect(subject[2].target_status.association(:account)).to be_loaded
        expect(subject[2].target_status).to eq reblog.reblog

        # follow: nothing
        expect(subject[3].type).to eq :follow
        expect(subject[3].target_status).to be_nil

        # follow_request: nothing
        expect(subject[4].type).to eq :follow_request
        expect(subject[4].target_status).to be_nil

        # favourite
        expect(subject[5].type).to eq :favourite
        expect(subject[5].target_status.association(:account)).to be_loaded
        expect(subject[5].target_status).to eq favourite.status

        # poll
        expect(subject[6].type).to eq :poll
        expect(subject[6].target_status.association(:account)).to be_loaded
        expect(subject[6].target_status).to eq poll.status
      context 'with a cached status' do
        it 'replaces mention' do
          expect(subject[0].type).to eq :mention
          expect(subject[0].target_status.association(:account)).to be_loaded
          expect(subject[0].target_status).to eq mention.status
        end

        it 'replaces status' do
          expect(subject[1].type).to eq :status
          expect(subject[1].target_status.association(:account)).to be_loaded
          expect(subject[1].target_status).to eq status
        end

        it 'replaces reblog' do
          expect(subject[2].type).to eq :reblog
          expect(subject[2].target_status.association(:account)).to be_loaded
          expect(subject[2].target_status).to eq reblog.reblog
        end

        it 'replaces follow' do
          expect(subject[3].type).to eq :follow
          expect(subject[3].target_status).to be_nil
        end

        it 'replaces follow_request' do
          expect(subject[4].type).to eq :follow_request
          expect(subject[4].target_status).to be_nil
        end

        it 'replaces favourite' do
          expect(subject[5].type).to eq :favourite
          expect(subject[5].target_status.association(:account)).to be_loaded
          expect(subject[5].target_status).to eq favourite.status
        end

        it 'replaces poll' do
          expect(subject[6].type).to eq :poll
          expect(subject[6].target_status.association(:account)).to be_loaded
          expect(subject[6].target_status).to eq poll.status
        end
      end
    end
  end

M spec/rails_helper.rb => spec/rails_helper.rb +12 -0
@@ 79,6 79,7 @@ RSpec.configure do |config|

  config.before :each, type: :cli do
    stub_stdout
    stub_reset_connection_pools
  end

  config.before :each, type: :feature do


@@ 121,9 122,20 @@ def attachment_fixture(name)
end

def stub_stdout
  # TODO: Is there a bettery way to:
  # - Avoid CLI command output being printed out
  # - Allow rspec to assert things against STDOUT
  # - Avoid disabling stdout for other desirable output (deprecation warnings, for example)
  allow($stdout).to receive(:write)
end

def stub_reset_connection_pools
  # TODO: Is there a better way to correctly run specs without stubbing this?
  # (Avoids reset_connection_pools! in test env)
  allow(ActiveRecord::Base).to receive(:establish_connection)
  allow(RedisConfiguration).to receive(:establish_pool)
end

def stub_jsonld_contexts!
  stub_request(:get, 'https://www.w3.org/ns/activitystreams').to_return(request_fixture('json-ld.activitystreams.txt'))
  stub_request(:get, 'https://w3id.org/identity/v1').to_return(request_fixture('json-ld.identity.txt'))

A spec/requests/api/v1/accounts_show_spec.rb => spec/requests/api/v1/accounts_show_spec.rb +53 -0
@@ 0,0 1,53 @@
# frozen_string_literal: true

require 'rails_helper'

describe 'GET /api/v1/accounts/{account_id}' do
  it 'returns account entity as 200 OK' do
    account = Fabricate(:account)

    get "/api/v1/accounts/#{account.id}"

    aggregate_failures do
      expect(response).to have_http_status(200)
      expect(body_as_json[:id]).to eq(account.id.to_s)
    end
  end

  it 'returns 404 if account not found' do
    get '/api/v1/accounts/1'

    aggregate_failures do
      expect(response).to have_http_status(404)
      expect(body_as_json[:error]).to eq('Record not found')
    end
  end

  context 'when with token' do
    it 'returns account entity as 200 OK if token is valid' do
      account = Fabricate(:account)
      user = Fabricate(:user, account: account)
      token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:accounts').token

      get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" }

      aggregate_failures do
        expect(response).to have_http_status(200)
        expect(body_as_json[:id]).to eq(account.id.to_s)
      end
    end

    it 'returns 403 if scope of token is invalid' do
      account = Fabricate(:account)
      user = Fabricate(:user, account: account)
      token = Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'write:statuses').token

      get "/api/v1/accounts/#{account.id}", headers: { Authorization: "Bearer #{token}" }

      aggregate_failures do
        expect(response).to have_http_status(403)
        expect(body_as_json[:error]).to eq('This action is outside the authorized scopes')
      end
    end
  end
end

R spec/controllers/api/v1/admin/canonical_email_blocks_controller_spec.rb => spec/requests/api/v1/admin/canonical_email_blocks_spec.rb +100 -153
@@ 2,22 2,19 @@

require 'rails_helper'

describe Api::V1::Admin::CanonicalEmailBlocksController do
  render_views

RSpec.describe 'Canonical Email Blocks' do
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:scopes)  { 'admin:read:canonical_email_blocks admin:write:canonical_email_blocks' }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end


@@ 26,65 23,54 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #index' do
    context 'with wrong scope' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
  describe 'GET /api/v1/admin/canonical_email_blocks' do
    subject do
      get '/api/v1/admin/canonical_email_blocks', headers: headers, params: params
    end

    context 'with wrong role' do
      before do
        get :index
      end
    let(:params) { {} }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      get :index
      subject

      expect(response).to have_http_status(200)
    end

    context 'when there is no canonical email block' do
      it 'returns an empty list' do
        get :index

        body = body_as_json
        subject

        expect(body).to be_empty
        expect(body_as_json).to be_empty
      end
    end

    context 'when there are canonical email blocks' do
      let!(:canonical_email_blocks) { Fabricate.times(5, :canonical_email_block) }
      let(:expected_email_hashes) { canonical_email_blocks.pluck(:canonical_email_hash) }
      let(:expected_email_hashes)   { canonical_email_blocks.pluck(:canonical_email_hash) }

      it 'returns the correct canonical email hashes' do
        get :index
        subject

        json = body_as_json

        expect(json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes)
        expect(body_as_json.pluck(:canonical_email_hash)).to match_array(expected_email_hashes)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of canonical email blocks' do
          get :index, params: params
          subject

          json = body_as_json

          expect(json.size).to eq(params[:limit])
          expect(body_as_json.size).to eq(params[:limit])
        end
      end



@@ 92,12 78,11 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
        let(:params) { { since_id: canonical_email_blocks[1].id } }

        it 'returns only the canonical email blocks after since_id' do
          get :index, params: params
          subject

          canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..])
          expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[2..])
        end
      end



@@ 105,47 90,36 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
        let(:params) { { max_id: canonical_email_blocks[3].id } }

        it 'returns only the canonical email blocks before max_id' do
          get :index, params: params
          subject

          canonical_email_blocks_ids = canonical_email_blocks.pluck(:id).map(&:to_s)
          json = body_as_json

          expect(json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2])
          expect(body_as_json.pluck(:id)).to match_array(canonical_email_blocks_ids[..2])
        end
      end
    end
  end

  describe 'GET #show' do
    let!(:canonical_email_block) { Fabricate(:canonical_email_block) }
    let(:params) { { id: canonical_email_block.id } }

    context 'with wrong scope' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
  describe 'GET /api/v1/admin/canonical_email_blocks/:id' do
    subject do
      get "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers
    end

    context 'with wrong role' do
      before do
        get :show, params: params
      end
    let!(:canonical_email_block) { Fabricate(:canonical_email_block) }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    context 'when canonical email block exists' do
    context 'when the requested canonical email block exists' do
      it 'returns http success' do
        get :show, params: params
        subject

        expect(response).to have_http_status(200)
      end

      it 'returns canonical email block data correctly' do
        get :show, params: params
      it 'returns the requested canonical email block data correctly' do
        subject

        json = body_as_json



@@ 154,138 128,116 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
      end
    end

    context 'when canonical block does not exist' do
    context 'when the requested canonical block does not exist' do
      it 'returns http not found' do
        get :show, params: { id: 0 }
        get '/api/v1/admin/canonical_email_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'POST #test' do
    context 'with wrong scope' do
      before do
        post :test
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
  describe 'POST /api/v1/admin/canonical_email_blocks/test' do
    subject do
      post '/api/v1/admin/canonical_email_blocks/test', headers: headers, params: params
    end

    context 'with wrong role' do
      before do
        post :test, params: { email: 'whatever@email.com' }
      end
    let(:params) { { email: 'email@example.com' } }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    context 'when the required email param is not provided' do
      let(:params) { {} }

    context 'when required email is not provided' do
      it 'returns http bad request' do
        post :test
        subject

        expect(response).to have_http_status(400)
      end
    end

    context 'when required email is provided' do
      let(:params) { { email: 'example@email.com' } }

    context 'when the required email param is provided' do
      context 'when there is a matching canonical email block' do
        let!(:canonical_email_block) { CanonicalEmailBlock.create(params) }

        it 'returns http success' do
          post :test, params: params
          subject

          expect(response).to have_http_status(200)
        end

        it 'returns expected canonical email hash' do
          post :test, params: params

          json = body_as_json
        it 'returns the expected canonical email hash' do
          subject

          expect(json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
          expect(body_as_json[0][:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
        end
      end

      context 'when there is no matching canonical email block' do
        it 'returns http success' do
          post :test, params: params
          subject

          expect(response).to have_http_status(200)
        end

        it 'returns an empty list' do
          post :test, params: params
          subject

          json = body_as_json

          expect(json).to be_empty
          expect(body_as_json).to be_empty
        end
      end
    end
  end

  describe 'POST #create' do
    let(:params) { { email: 'example@email.com' } }
    let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) }

    context 'with wrong scope' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
  describe 'POST /api/v1/admin/canonical_email_blocks' do
    subject do
      post '/api/v1/admin/canonical_email_blocks', headers: headers, params: params
    end

    context 'with wrong role' do
      before do
        post :create, params: params
      end
    let(:params)                { { email: 'example@email.com' } }
    let(:canonical_email_block) { CanonicalEmailBlock.new(email: params[:email]) }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      post :create, params: params
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns canonical_email_hash correctly' do
      post :create, params: params
    it 'returns the canonical_email_hash correctly' do
      subject

      json = body_as_json

      expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
      expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
    end

    context 'when required email param is not provided' do
    context 'when the required email param is not provided' do
      let(:params) { {} }

      it 'returns http unprocessable entity' do
        post :create
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when canonical_email_hash param is provided instead of email' do
    context 'when the canonical_email_hash param is provided instead of email' do
      let(:params) { { canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } }

      it 'returns http success' do
        post :create, params: params
        subject

        expect(response).to have_http_status(200)
      end

      it 'returns correct canonical_email_hash' do
        post :create, params: params

        json = body_as_json
      it 'returns the correct canonical_email_hash' do
        subject

        expect(json[:canonical_email_hash]).to eq(params[:canonical_email_hash])
        expect(body_as_json[:canonical_email_hash]).to eq(params[:canonical_email_hash])
      end
    end



@@ 293,63 245,58 @@ describe Api::V1::Admin::CanonicalEmailBlocksController do
      let(:params) { { email: 'example@email.com', canonical_email_hash: 'dd501ce4e6b08698f19df96f2f15737e48a75660b1fa79b6ff58ea25ee4851a4' } }

      it 'returns http success' do
        post :create, params: params
        subject

        expect(response).to have_http_status(200)
      end

      it 'ignores canonical_email_hash param' do
        post :create, params: params

        json = body_as_json
      it 'ignores the canonical_email_hash param' do
        subject

        expect(json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
        expect(body_as_json[:canonical_email_hash]).to eq(canonical_email_block.canonical_email_hash)
      end
    end

    context 'when canonical email was already blocked' do
    context 'when the given canonical email was already blocked' do
      before do
        canonical_email_block.save
      end

      it 'returns http unprocessable entity' do
        post :create, params: params
        subject

        expect(response).to have_http_status(422)
      end
    end
  end

  describe 'DELETE #destroy' do
  describe 'DELETE /api/v1/admin/canonical_email_blocks/:id' do
    subject do
      delete "/api/v1/admin/canonical_email_blocks/#{canonical_email_block.id}", headers: headers
    end

    let!(:canonical_email_block) { Fabricate(:canonical_email_block) }
    let(:params) { { id: canonical_email_block.id } }

    context 'with wrong scope' do
      before do
        delete :destroy, params: params
      end
    it_behaves_like 'forbidden for wrong scope', 'read:statuses'

      it_behaves_like 'forbidden for wrong scope', 'read:statuses'
    end
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    context 'with wrong role' do
      before do
        delete :destroy, params: params
      end
    it 'returns http success' do
      subject

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
      expect(response).to have_http_status(200)
    end

    it 'returns http success' do
      delete :destroy, params: params
    it 'deletes the canonical email block' do
      subject

      expect(response).to have_http_status(200)
      expect(CanonicalEmailBlock.find_by(id: canonical_email_block.id)).to be_nil
    end

    context 'when canonical email block is not found' do
    context 'when the canonical email block is not found' do
      it 'returns http not found' do
        delete :destroy, params: { id: 0 }
        delete '/api/v1/admin/canonical_email_blocks/0', headers: headers

        expect(response).to have_http_status(404)
      end

R spec/controllers/api/v1/admin/domain_allows_controller_spec.rb => spec/requests/api/v1/admin/domain_allows_spec.rb +134 -60
@@ 2,22 2,19 @@

require 'rails_helper'

RSpec.describe Api::V1::Admin::DomainAllowsController do
  render_views

  let(:role)   { UserRole.find_by(name: 'Admin') }
  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) }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end
RSpec.describe 'Domain Allows' do
  let(:role)    { UserRole.find_by(name: 'Admin') }
  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(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end


@@ 26,115 23,192 @@ RSpec.describe Api::V1::Admin::DomainAllowsController do
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #index' do
    let!(:domain_allow) { Fabricate(:domain_allow) }

    before do
      get :index
  describe 'GET /api/v1/admin/domain_allows' do
    subject do
      get '/api/v1/admin/domain_allows', headers: headers, params: params
    end

    let(:params) { {} }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the expected domain allows' do
      json = body_as_json
      expect(json.length).to eq 1
      expect(json[0][:id].to_i).to eq domain_allow.id
    context 'when there is no allowed domains' do
      it 'returns an empty body' do
        subject

        expect(body_as_json).to be_empty
      end
    end
  end

  describe 'GET #show' do
    let!(:domain_allow) { Fabricate(:domain_allow) }
    context 'when there are allowed domains' do
      let!(:domain_allows) { Fabricate.times(5, :domain_allow) }
      let(:expected_response) do
        domain_allows.map do |domain_allow|
          {
            id: domain_allow.id.to_s,
            domain: domain_allow.domain,
            created_at: domain_allow.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
          }
        end
      end

    before do
      get :show, params: { id: domain_allow.id }
    end
      it 'returns the correct allowed domains' do
        subject

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'
        expect(body_as_json).to match_array(expected_response)
      end

    it 'returns http success' do
      expect(response).to have_http_status(200)
    end
      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of allowed domains' do
          subject

    it 'returns expected domain name' do
      json = body_as_json
      expect(json[:domain]).to eq domain_allow.domain
          expect(body_as_json.size).to eq(params[:limit])
        end
      end
    end
  end

  describe 'DELETE #destroy' do
    let!(:domain_allow) { Fabricate(:domain_allow) }

    before do
      delete :destroy, params: { id: domain_allow.id }
  describe 'GET /api/v1/admin/domain_allows/:id' do
    subject do
      get "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers
    end

    let!(:domain_allow) { Fabricate(:domain_allow) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'deletes the block' do
      expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil
    it 'returns the expected allowed domain name' do
      subject

      expect(body_as_json[:domain]).to eq domain_allow.domain
    end
  end

  describe 'POST #create' do
    let!(:domain_allow) { Fabricate(:domain_allow, domain: 'example.com') }
    context 'when the requested allowed domain does not exist' do
      it 'returns http not found' do
        get '/api/v1/admin/domain_allows/-1', headers: headers

    context 'with a valid domain' do
      before do
        post :create, params: { domain: 'foo.bar.com' }
        expect(response).to have_http_status(404)
      end
    end
  end

      it_behaves_like 'forbidden for wrong scope', 'write:statuses'
      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
  describe 'POST /api/v1/admin/domain_allows' do
    subject do
      post '/api/v1/admin/domain_allows', headers: headers, params: params
    end

    let(:params) { { domain: 'foo.bar.com' } }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    context 'with a valid domain name' do
      it 'returns http success' do
        subject

        expect(response).to have_http_status(200)
      end

      it 'returns expected domain name' do
        json = body_as_json
        expect(json[:domain]).to eq 'foo.bar.com'
      it 'returns the expected domain name' do
        subject

        expect(body_as_json[:domain]).to eq 'foo.bar.com'
      end

      it 'creates a domain block' do
        expect(DomainAllow.find_by(domain: 'foo.bar.com')).to_not be_nil
      it 'creates a domain allow' do
        subject

        expect(DomainAllow.find_by(domain: 'foo.bar.com')).to be_present
      end
    end

    context 'with invalid domain name' do
      before do
        post :create, params: { domain: 'foo bar' }
      end
      let(:params) { 'foo bar' }

      it 'returns http unprocessable entity' do
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when domain name is not specified' do
      let(:params) { {} }

      it 'returns http unprocessable entity' do
        post :create
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when the domain is already allowed' do
      before do
        DomainAllow.create(params)
      end

      it 'returns the existing allowed domain name' do
        subject

        expect(body_as_json[:domain]).to eq(params[:domain])
      end
    end
  end

  describe 'DELETE /api/v1/admin/domain_allows/:id' do
    subject do
      delete "/api/v1/admin/domain_allows/#{domain_allow.id}", headers: headers
    end

    let!(:domain_allow) { Fabricate(:domain_allow) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'deletes the allowed domain' do
      subject

      expect(DomainAllow.find_by(id: domain_allow.id)).to be_nil
    end

    context 'when the allowed domain does not exist' do
      it 'returns http not found' do
        delete '/api/v1/admin/domain_allows/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end
end

A spec/requests/api/v1/admin/domain_blocks_spec.rb => spec/requests/api/v1/admin/domain_blocks_spec.rb +284 -0
@@ 0,0 1,284 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Domain Blocks' do
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:scopes)  { 'admin:read:domain_blocks admin:write:domain_blocks' }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  describe 'GET /api/v1/admin/domain_blocks' do
    subject do
      get '/api/v1/admin/domain_blocks', headers: headers, params: params
    end

    let(:params) { {} }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    context 'when there are no domain blocks' do
      it 'returns an empty list' do
        subject

        expect(body_as_json).to be_empty
      end
    end

    context 'when there are domain blocks' do
      let!(:domain_blocks) do
        [
          Fabricate(:domain_block, severity: :silence, reject_media: true),
          Fabricate(:domain_block, severity: :suspend, obfuscate: true),
          Fabricate(:domain_block, severity: :noop, reject_reports: true),
          Fabricate(:domain_block, public_comment: 'Spam'),
          Fabricate(:domain_block, private_comment: 'Spam'),
        ]
      end
      let(:expected_responde) do
        domain_blocks.map do |domain_block|
          {
            id: domain_block.id.to_s,
            domain: domain_block.domain,
            created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
            severity: domain_block.severity.to_s,
            reject_media: domain_block.reject_media,
            reject_reports: domain_block.reject_reports,
            private_comment: domain_block.private_comment,
            public_comment: domain_block.public_comment,
            obfuscate: domain_block.obfuscate,
          }
        end
      end

      it 'returns the expected domain blocks' do
        subject

        expect(body_as_json).to match_array(expected_responde)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of domain blocks' do
          subject

          expect(body_as_json.size).to eq(params[:limit])
        end
      end
    end
  end

  describe 'GET /api/v1/admin/domain_blocks/:id' do
    subject do
      get "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers
    end

    let!(:domain_block) { Fabricate(:domain_block) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the expected domain block content' do
      subject

      expect(body_as_json).to eq(
        {
          id: domain_block.id.to_s,
          domain: domain_block.domain,
          created_at: domain_block.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
          severity: domain_block.severity.to_s,
          reject_media: domain_block.reject_media,
          reject_reports: domain_block.reject_reports,
          private_comment: domain_block.private_comment,
          public_comment: domain_block.public_comment,
          obfuscate: domain_block.obfuscate,
        }
      )
    end

    context 'when the requested domain block does not exist' do
      it 'returns http not found' do
        get '/api/v1/admin/domain_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'POST /api/v1/admin/domain_blocks' do
    subject do
      post '/api/v1/admin/domain_blocks', headers: headers, params: params
    end

    let(:params) { { domain: 'foo.bar.com', severity: :silence } }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns expected domain name and severity' do
      subject

      body = body_as_json

      expect(body).to match a_hash_including(
        {
          domain: 'foo.bar.com',
          severity: 'silence',
        }
      )
    end

    it 'creates a domain block' do
      subject

      expect(DomainBlock.find_by(domain: 'foo.bar.com')).to be_present
    end

    context 'when a stricter domain block already exists' do
      before do
        Fabricate(:domain_block, domain: 'bar.com', severity: :suspend)
      end

      it 'returns http unprocessable entity' do
        subject

        expect(response).to have_http_status(422)
      end

      it 'returns existing domain block in error' do
        subject

        expect(body_as_json[:existing_domain_block][:domain]).to eq('bar.com')
      end
    end

    context 'when given domain name is invalid' do
      let(:params) { { domain: 'foo bar', severity: :silence } }

      it 'returns http unprocessable entity' do
        subject

        expect(response).to have_http_status(422)
      end
    end
  end

  describe 'PUT /api/v1/admin/domain_blocks/:id' do
    subject do
      put "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers, params: params
    end

    let!(:domain_block)   { Fabricate(:domain_block, domain: 'example.com', severity: :silence) }
    let(:params)          { { domain: 'example.com', severity: 'suspend' } }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the updated domain block' do
      subject

      expect(body_as_json).to match a_hash_including(
        {
          id: domain_block.id.to_s,
          domain: domain_block.domain,
          severity: 'suspend',
        }
      )
    end

    it 'updates the block severity' do
      expect { subject }.to change { domain_block.reload.severity }.from('silence').to('suspend')
    end

    context 'when domain block does not exist' do
      it 'returns http not found' do
        put '/api/v1/admin/domain_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'DELETE /api/v1/admin/domain_blocks/:id' do
    subject do
      delete "/api/v1/admin/domain_blocks/#{domain_block.id}", headers: headers
    end

    let!(:domain_block) { Fabricate(:domain_block) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'deletes the domain block' do
      subject

      expect(DomainBlock.find_by(id: domain_block.id)).to be_nil
    end

    context 'when domain block does not exist' do
      it 'returns http not found' do
        delete '/api/v1/admin/domain_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end
end

R spec/controllers/api/v1/admin/ip_blocks_controller_spec.rb => spec/requests/api/v1/admin/ip_blocks_spec.rb +88 -122
@@ 2,22 2,19 @@

require 'rails_helper'

describe Api::V1::Admin::IpBlocksController do
  render_views

RSpec.describe 'IP Blocks' do
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:scopes)  { 'admin:read:ip_blocks admin:write:ip_blocks' }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end


@@ 26,41 23,34 @@ describe Api::V1::Admin::IpBlocksController do
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  describe 'GET #index' do
    context 'with wrong scope' do
      before do
        get :index
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
  describe 'GET /api/v1/admin/ip_blocks' do
    subject do
      get '/api/v1/admin/ip_blocks', headers: headers, params: params
    end

    context 'with wrong role' do
      before do
        get :index
      end
    let(:params) { {} }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      get :index
      subject

      expect(response).to have_http_status(200)
    end

    context 'when there is no ip block' do
      it 'returns an empty body' do
        get :index

        json = body_as_json
        subject

        expect(json).to be_empty
        expect(body_as_json).to be_empty
      end
    end



@@ 86,56 76,42 @@ describe Api::V1::Admin::IpBlocksController do
      end

      it 'returns the correct blocked ips' do
        get :index

        json = body_as_json
        subject

        expect(json).to match_array(expected_response)
        expect(body_as_json).to match_array(expected_response)
      end

      context 'with limit param' do
        let(:params) { { limit: 2 } }

        it 'returns only the requested number of ip blocks' do
          get :index, params: params
          subject

          json = body_as_json

          expect(json.size).to eq(params[:limit])
          expect(body_as_json.size).to eq(params[:limit])
        end
      end
    end
  end

  describe 'GET #show' do
    let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) }
    let(:params) { { id: ip_block.id } }

    context 'with wrong scope' do
      before do
        get :show, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
  describe 'GET /api/v1/admin/ip_blocks/:id' do
    subject do
      get "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers
    end

    context 'with wrong role' do
      before do
        get :show, params: params
      end
    let!(:ip_block) { IpBlock.create(ip: '192.0.2.0/24', severity: :no_access) }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'admin:write:ip_blocks'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      get :show, params: params
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the correct ip block' do
      get :show, params: params
      subject

      json = body_as_json



@@ 145,41 121,32 @@ describe Api::V1::Admin::IpBlocksController do

    context 'when ip block does not exist' do
      it 'returns http not found' do
        get :show, params: { id: 0 }
        get '/api/v1/admin/ip_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'POST #create' do
    let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } }

    context 'with wrong scope' do
      before do
        post :create, params: params
      end

      it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks'
  describe 'POST /api/v1/admin/ip_blocks' do
    subject do
      post '/api/v1/admin/ip_blocks', headers: headers, params: params
    end

    context 'with wrong role' do
      before do
        post :create, params: params
      end
    let(:params) { { ip: '151.0.32.55', severity: 'no_access', comment: 'Spam' } }

      it_behaves_like 'forbidden for wrong role', ''
      it_behaves_like 'forbidden for wrong role', 'Moderator'
    end
    it_behaves_like 'forbidden for wrong scope', 'admin:read:ip_blocks'
    it_behaves_like 'forbidden for wrong role', ''
    it_behaves_like 'forbidden for wrong role', 'Moderator'

    it 'returns http success' do
      post :create, params: params
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the correct ip block' do
      post :create, params: params
      subject

      json = body_as_json



@@ 188,119 155,118 @@ describe Api::V1::Admin::IpBlocksController do
      expect(json[:comment]).to eq(params[:comment])
    end

    context 'when ip is not provided' do
    context 'when the required ip param is not provided' do
      let(:params) { { ip: '', severity: 'no_access' } }

      it 'returns http unprocessable entity' do
        post :create, params: params
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when severity is not provided' do
    context 'when the required severity param is not provided' do
      let(:params) { { ip: '173.65.23.1', severity: '' } }

      it 'returns http unprocessable entity' do
        post :create, params: params
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when provided ip is already blocked' do
    context 'when the given ip address is already blocked' do
      before do
        IpBlock.create(params)
      end

      it 'returns http unprocessable entity' do
        post :create, params: params
        subject

        expect(response).to have_http_status(422)
      end
    end

    context 'when provided ip address is invalid' do
    context 'when the given ip address is invalid' do
      let(:params) { { ip: '520.13.54.120', severity: 'no_access' } }

      it 'returns http unprocessable entity' do
        post :create, params: params
        subject

        expect(response).to have_http_status(422)
      end
    end
  end

  describe 'PUT #update' do
    context 'when ip block exists' do
      let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) }
      let(:params) { { id: ip_block.id, severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } }
  describe 'PUT /api/v1/admin/ip_blocks/:id' do
    subject do
      put "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers, params: params
    end

      it 'returns http success' do
        put :update, params: params
    let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access', comment: 'Spam', expires_in: 48.hours) }
    let(:params)    { { severity: 'sign_up_requires_approval', comment: 'Decreasing severity' } }

        expect(response).to have_http_status(200)
      end
    it 'returns http success' do
      subject

      it 'returns the correct ip block' do
        put :update, params: params
      expect(response).to have_http_status(200)
    end

        json = body_as_json
    it 'returns the correct ip block' do
      subject

        expect(json).to match(hash_including({
          ip: "#{ip_block.ip}/#{ip_block.ip.prefix}",
          severity: 'sign_up_requires_approval',
          comment: 'Decreasing severity',
        }))
      end
      expect(body_as_json).to match(hash_including({
        ip: "#{ip_block.ip}/#{ip_block.ip.prefix}",
        severity: 'sign_up_requires_approval',
        comment: 'Decreasing severity',
      }))
    end

      it 'updates the severity correctly' do
        expect { put :update, params: params }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval')
      end
    it 'updates the severity correctly' do
      expect { subject }.to change { ip_block.reload.severity }.from('no_access').to('sign_up_requires_approval')
    end

      it 'updates the comment correctly' do
        expect { put :update, params: params }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity')
      end
    it 'updates the comment correctly' do
      expect { subject }.to change { ip_block.reload.comment }.from('Spam').to('Decreasing severity')
    end

    context 'when ip block does not exist' do
      it 'returns http not found' do
        put :update, params: { id: 0 }
        put '/api/v1/admin/ip_blocks/-1', headers: headers, params: params

        expect(response).to have_http_status(404)
      end
    end
  end

  describe 'DELETE #destroy' do
    context 'when ip block exists' do
      let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') }
      let(:params) { { id: ip_block.id } }
  describe 'DELETE /api/v1/admin/ip_blocks/:id' do
    subject do
      delete "/api/v1/admin/ip_blocks/#{ip_block.id}", headers: headers
    end

      it 'returns http success' do
        delete :destroy, params: params
    let!(:ip_block) { IpBlock.create(ip: '185.200.13.3', severity: 'no_access') }

        expect(response).to have_http_status(200)
      end
    it 'returns http success' do
      subject

      it 'returns an empty body' do
        delete :destroy, params: params
      expect(response).to have_http_status(200)
    end

        json = body_as_json
    it 'returns an empty body' do
      subject

        expect(json).to be_empty
      end
      expect(body_as_json).to be_empty
    end

      it 'deletes the ip block' do
        delete :destroy, params: params
    it 'deletes the ip block' do
      subject

        expect(IpBlock.find_by(id: ip_block.id)).to be_nil
      end
      expect(IpBlock.find_by(id: ip_block.id)).to be_nil
    end

    context 'when ip block does not exist' do
      it 'returns http not found' do
        delete :destroy, params: { id: 0 }
        delete '/api/v1/admin/ip_blocks/-1', headers: headers

        expect(response).to have_http_status(404)
      end

A spec/requests/api/v1/admin/reports_spec.rb => spec/requests/api/v1/admin/reports_spec.rb +292 -0
@@ 0,0 1,292 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Reports' do
  let(:role)    { UserRole.find_by(name: 'Admin') }
  let(:user)    { Fabricate(:user, role: role) }
  let(:scopes)  { 'admin:read:reports admin:write:reports' }
  let(:token)   { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
  let(:headers) { { 'Authorization' => "Bearer #{token.token}" } }

  shared_examples 'forbidden for wrong scope' do |wrong_scope|
    let(:scopes) { wrong_scope }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  shared_examples 'forbidden for wrong role' do |wrong_role|
    let(:role) { UserRole.find_by(name: wrong_role) }

    it 'returns http forbidden' do
      subject

      expect(response).to have_http_status(403)
    end
  end

  describe 'GET /api/v1/admin/reports' do
    subject do
      get '/api/v1/admin/reports', headers: headers, params: params
    end

    let(:params) { {} }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    context 'when there are no reports' do
      it 'returns an empty list' do
        subject

        expect(body_as_json).to be_empty
      end
    end

    context 'when there are reports' do
      let!(:reporter) { Fabricate(:account) }
      let!(:spammer)  { Fabricate(:account) }
      let(:expected_response) do
        scope.map do |report|
          hash_including({
            id: report.id.to_s,
            action_taken: report.action_taken?,
            category: report.category,
            comment: report.comment,
            account: hash_including(id: report.account.id.to_s),
            target_account: hash_including(id: report.target_account.id.to_s),
            statuses: report.statuses,
            rules: report.rules,
            forwarded: report.forwarded,
          })
        end
      end
      let(:scope) { Report.unresolved }

      before do
        Fabricate(:report)
        Fabricate(:report, target_account: spammer)
        Fabricate(:report, account: reporter, target_account: spammer)
        Fabricate(:report, action_taken_at: 4.days.ago, account: reporter)
        Fabricate(:report, action_taken_at: 20.days.ago)
      end

      it 'returns all unresolved reports' do
        subject

        expect(body_as_json).to match_array(expected_response)
      end

      context 'with resolved param' do
        let(:params) { { resolved: true } }
        let(:scope)  { Report.resolved }

        it 'returns only the resolved reports' do
          subject

          expect(body_as_json).to match_array(expected_response)
        end
      end

      context 'with account_id param' do
        let(:params) { { account_id: reporter.id } }
        let(:scope)  { Report.unresolved.where(account: reporter) }

        it 'returns all unresolved reports filed by the specified account' do
          subject

          expect(body_as_json).to match_array(expected_response)
        end
      end

      context 'with target_account_id param' do
        let(:params) { { target_account_id: spammer.id } }
        let(:scope)  { Report.unresolved.where(target_account: spammer) }

        it 'returns all unresolved reports targeting the specified account' do
          subject

          expect(body_as_json).to match_array(expected_response)
        end
      end

      context 'with limit param' do
        let(:params) { { limit: 1 } }

        it 'returns only the requested number of reports' do
          subject

          expect(body_as_json.size).to eq(1)
        end
      end
    end
  end

  describe 'GET /api/v1/admin/reports/:id' do
    subject do
      get "/api/v1/admin/reports/#{report.id}", headers: headers
    end

    let(:report) { Fabricate(:report) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'returns the requested report content' do
      subject

      expect(body_as_json).to include(
        {
          id: report.id.to_s,
          action_taken: report.action_taken?,
          category: report.category,
          comment: report.comment,
          account: a_hash_including(id: report.account.id.to_s),
          target_account: a_hash_including(id: report.target_account.id.to_s),
          statuses: report.statuses,
          rules: report.rules,
          forwarded: report.forwarded,
        }
      )
    end
  end

  describe 'PUT /api/v1/admin/reports/:id' do
    subject do
      put "/api/v1/admin/reports/#{report.id}", headers: headers, params: params
    end

    let!(:report) { Fabricate(:report, category: :other) }
    let(:params)  { { category: 'spam' } }

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'updates the report category' do
      expect { subject }.to change { report.reload.category }.from('other').to('spam')
    end

    it 'returns the updated report content' do
      subject

      report.reload

      expect(body_as_json).to include(
        {
          id: report.id.to_s,
          action_taken: report.action_taken?,
          category: report.category,
          comment: report.comment,
          account: a_hash_including(id: report.account.id.to_s),
          target_account: a_hash_including(id: report.target_account.id.to_s),
          statuses: report.statuses,
          rules: report.rules,
          forwarded: report.forwarded,
        }
      )
    end
  end

  describe 'POST #resolve' do
    subject do
      post "/api/v1/admin/reports/#{report.id}/resolve", headers: headers
    end

    let(:report) { Fabricate(:report, action_taken_at: nil) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'marks report as resolved' do
      expect { subject }.to change { report.reload.unresolved? }.from(true).to(false)
    end
  end

  describe 'POST #reopen' do
    subject do
      post "/api/v1/admin/reports/#{report.id}/reopen", headers: headers
    end

    let(:report) { Fabricate(:report, action_taken_at: 10.days.ago) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'marks report as unresolved' do
      expect { subject }.to change { report.reload.unresolved? }.from(false).to(true)
    end
  end

  describe 'POST #assign_to_self' do
    subject do
      post "/api/v1/admin/reports/#{report.id}/assign_to_self", headers: headers
    end

    let(:report) { Fabricate(:report) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'assigns report to the requesting user' do
      expect { subject }.to change { report.reload.assigned_account_id }.from(nil).to(user.account.id)
    end
  end

  describe 'POST #unassign' do
    subject do
      post "/api/v1/admin/reports/#{report.id}/unassign", headers: headers
    end

    let(:report) { Fabricate(:report, assigned_account_id: user.account.id) }

    it_behaves_like 'forbidden for wrong scope', 'write:statuses'
    it_behaves_like 'forbidden for wrong role', ''

    it 'returns http success' do
      subject

      expect(response).to have_http_status(200)
    end

    it 'unassigns report from assignee' do
      expect { subject }.to change { report.reload.assigned_account_id }.from(user.account.id).to(nil)
    end
  end
end

M spec/services/translate_status_service_spec.rb => spec/services/translate_status_service_spec.rb +22 -13
@@ 152,22 152,31 @@ RSpec.describe TranslateStatusService, type: :service do
    describe 'status has poll' do
      let(:poll) { Fabricate(:poll, options: %w(Blue Green)) }

      it 'returns formatted poll options' do
        source_texts = service.send(:source_texts)
        expect(source_texts.size).to eq 3
        expect(source_texts.values).to eq %w(<p>Hello</p> Blue Green)
      context 'with source texts from the service' do
        let!(:source_texts) { service.send(:source_texts) }

        expect(source_texts.keys.first).to eq :content
        it 'returns formatted poll options' do
          expect(source_texts.size).to eq 3
          expect(source_texts.values).to eq %w(<p>Hello</p> Blue Green)
        end

        option1 = source_texts.keys.second
        expect(option1).to be_a Poll::Option
        expect(option1.id).to eq '0'
        expect(option1.title).to eq 'Blue'
        it 'has a first key with content' do
          expect(source_texts.keys.first).to eq :content
        end

        option2 = source_texts.keys.third
        expect(option2).to be_a Poll::Option
        expect(option2.id).to eq '1'
        expect(option2.title).to eq 'Green'
        it 'has the first option in the second key with correct options' do
          option1 = source_texts.keys.second
          expect(option1).to be_a Poll::Option
          expect(option1.id).to eq '0'
          expect(option1.title).to eq 'Blue'
        end

        it 'has the second option in the third key with correct options' do
          option2 = source_texts.keys.third
          expect(option2).to be_a Poll::Option
          expect(option2.id).to eq '1'
          expect(option2.title).to eq 'Green'
        end
      end
    end


D spec/support/examples/lib/settings/scoped_settings.rb => spec/support/examples/lib/settings/scoped_settings.rb +0 -74
@@ 1,74 0,0 @@
# frozen_string_literal: true

shared_examples 'ScopedSettings' do
  describe '[]' do
    it 'inherits default settings' do
      expect(Setting.boost_modal).to be false
      expect(Setting.interactions['must_be_follower']).to be false

      settings = create!

      expect(settings['boost_modal']).to be false
      expect(settings['interactions']['must_be_follower']).to be false
    end
  end

  describe 'all_as_records' do
    # expecting [] and []= works

    it 'returns records merged with default values except hashes' do
      expect(Setting.boost_modal).to be false
      expect(Setting.delete_modal).to be true

      settings = create!
      settings['boost_modal'] = true

      records = settings.all_as_records

      expect(records['boost_modal'].value).to be true
      expect(records['delete_modal'].value).to be true
    end
  end

  describe 'missing methods' do
    # expecting [] and []= works.

    it 'reads settings' do
      expect(Setting.boost_modal).to be false
      settings = create!
      expect(settings.boost_modal).to be false
    end

    it 'updates settings' do
      settings = fabricate
      settings.boost_modal = true
      expect(settings['boost_modal']).to be true
    end
  end

  it 'can update settings with [] and can read with []=' do
    settings = fabricate

    settings['boost_modal'] = true
    settings['interactions'] = settings['interactions'].merge('must_be_follower' => true)

    Setting.save!

    expect(settings['boost_modal']).to be true
    expect(settings['interactions']['must_be_follower']).to be true

    Rails.cache.clear

    expect(settings['boost_modal']).to be true
    expect(settings['interactions']['must_be_follower']).to be true
  end

  xit 'does not mutate defaults via the cache' do
    fabricate['interactions']['must_be_follower'] = true
    # TODO
    # This mutates the global settings default such that future
    # instances will inherit the incorrect starting values

    expect(fabricate.settings['interactions']['must_be_follower']).to be false
  end
end

D spec/support/examples/lib/settings/settings_extended.rb => spec/support/examples/lib/settings/settings_extended.rb +0 -15
@@ 1,15 0,0 @@
# frozen_string_literal: true

shared_examples 'Settings-extended' do
  describe 'settings' do
    def fabricate
      super.settings
    end

    def create!
      super.settings
    end

    it_behaves_like 'ScopedSettings'
  end
end

M spec/support/examples/models/concerns/account_avatar.rb => spec/support/examples/models/concerns/account_avatar.rb +1 -1
@@ 1,7 1,7 @@
# frozen_string_literal: true

shared_examples 'AccountAvatar' do |fabricator|
  describe 'static avatars' do
  describe 'static avatars', paperclip_processing: true do
    describe 'when GIF' do
      it 'creates a png static style' do
        account = Fabricate(fabricator, avatar: attachment_fixture('avatar.gif'))

M streaming/index.js => streaming/index.js +5 -1
@@ 835,7 835,11 @@ const startServer = async () => {
      return;
    }

    ws.send(JSON.stringify({ stream: streamName, event, payload }));
    ws.send(JSON.stringify({ stream: streamName, event, payload }), (err) => {
      if (err) {
        log.error(req.requestId, `Failed to send to websocket: ${err}`);
      }
    });
  };

  /**