Add support for FASP data sharing (#34415)
This commit is contained in:
		@@ -0,0 +1,26 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Api::Fasp::DataSharing::V0::BackfillRequestsController < Api::Fasp::BaseController
 | 
			
		||||
  def create
 | 
			
		||||
    backfill_request = current_provider.fasp_backfill_requests.new(backfill_request_params)
 | 
			
		||||
 | 
			
		||||
    respond_to do |format|
 | 
			
		||||
      format.json do
 | 
			
		||||
        if backfill_request.save
 | 
			
		||||
          render json: { backfillRequest: { id: backfill_request.id } }, status: 201
 | 
			
		||||
        else
 | 
			
		||||
          head 422
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def backfill_request_params
 | 
			
		||||
    params
 | 
			
		||||
      .permit(:category, :maxCount)
 | 
			
		||||
      .to_unsafe_h
 | 
			
		||||
      .transform_keys { |k| k.to_s.underscore }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,10 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Api::Fasp::DataSharing::V0::ContinuationsController < Api::Fasp::BaseController
 | 
			
		||||
  def create
 | 
			
		||||
    backfill_request = current_provider.fasp_backfill_requests.find(params[:backfill_request_id])
 | 
			
		||||
    Fasp::BackfillWorker.perform_async(backfill_request.id)
 | 
			
		||||
 | 
			
		||||
    head 204
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,25 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Api::Fasp::DataSharing::V0::EventSubscriptionsController < Api::Fasp::BaseController
 | 
			
		||||
  def create
 | 
			
		||||
    subscription = current_provider.fasp_subscriptions.create!(subscription_params)
 | 
			
		||||
 | 
			
		||||
    render json: { subscription: { id: subscription.id } }, status: 201
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy
 | 
			
		||||
    subscription = current_provider.fasp_subscriptions.find(params[:id])
 | 
			
		||||
    subscription.destroy
 | 
			
		||||
 | 
			
		||||
    head 204
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def subscription_params
 | 
			
		||||
    params
 | 
			
		||||
      .permit(:category, :subscriptionType, :maxBatchSize, threshold: {})
 | 
			
		||||
      .to_unsafe_h
 | 
			
		||||
      .transform_keys { |k| k.to_s.underscore }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -32,6 +32,7 @@ class Fasp::Request
 | 
			
		||||
  def request_headers(verb, url, body = '')
 | 
			
		||||
    result = {
 | 
			
		||||
      'accept' => 'application/json',
 | 
			
		||||
      'content-type' => 'application/json',
 | 
			
		||||
      'content-digest' => content_digest(body),
 | 
			
		||||
    }
 | 
			
		||||
    result.merge(signature_headers(verb, url, result))
 | 
			
		||||
 
 | 
			
		||||
@@ -85,6 +85,7 @@ class Account < ApplicationRecord
 | 
			
		||||
  include Account::Associations
 | 
			
		||||
  include Account::Avatar
 | 
			
		||||
  include Account::Counters
 | 
			
		||||
  include Account::FaspConcern
 | 
			
		||||
  include Account::FinderConcern
 | 
			
		||||
  include Account::Header
 | 
			
		||||
  include Account::Interactions
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								app/models/concerns/account/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/models/concerns/account/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Account::FaspConcern
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    after_commit :announce_new_account_to_subscribed_fasp, on: :create
 | 
			
		||||
    after_commit :announce_updated_account_to_subscribed_fasp, on: :update
 | 
			
		||||
    after_commit :announce_deleted_account_to_subscribed_fasp, on: :destroy
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce_new_account_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless discoverable?
 | 
			
		||||
 | 
			
		||||
    uri = ActivityPub::TagManager.instance.uri_for(self)
 | 
			
		||||
    Fasp::AnnounceAccountLifecycleEventWorker.perform_async(uri, 'new')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce_updated_account_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless discoverable? || saved_change_to_discoverable?
 | 
			
		||||
 | 
			
		||||
    uri = ActivityPub::TagManager.instance.uri_for(self)
 | 
			
		||||
    Fasp::AnnounceAccountLifecycleEventWorker.perform_async(uri, 'update')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce_deleted_account_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless discoverable?
 | 
			
		||||
 | 
			
		||||
    uri = ActivityPub::TagManager.instance.uri_for(self)
 | 
			
		||||
    Fasp::AnnounceAccountLifecycleEventWorker.perform_async(uri, 'delete')
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										17
									
								
								app/models/concerns/favourite/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/models/concerns/favourite/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Favourite::FaspConcern
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    after_commit :announce_trends_to_subscribed_fasp, on: :create
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce_trends_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
 | 
			
		||||
    Fasp::AnnounceTrendWorker.perform_async(status_id, 'favourite')
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										53
									
								
								app/models/concerns/status/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/models/concerns/status/fasp_concern.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,53 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Status::FaspConcern
 | 
			
		||||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    after_commit :announce_new_content_to_subscribed_fasp, on: :create
 | 
			
		||||
    after_commit :announce_updated_content_to_subscribed_fasp, on: :update
 | 
			
		||||
    after_commit :announce_deleted_content_to_subscribed_fasp, on: :destroy
 | 
			
		||||
    after_commit :announce_trends_to_subscribed_fasp, on: :create
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce_new_content_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless account_indexable? && public_visibility?
 | 
			
		||||
 | 
			
		||||
    # We need the uri here, but it is set in another `after_commit`
 | 
			
		||||
    # callback. Hooks included from modules are run before the ones
 | 
			
		||||
    # in the class itself and can neither be reordered nor is there
 | 
			
		||||
    # a way to declare dependencies.
 | 
			
		||||
    store_uri if uri.nil?
 | 
			
		||||
    Fasp::AnnounceContentLifecycleEventWorker.perform_async(uri, 'new')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce_updated_content_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless account_indexable? && public_visibility?
 | 
			
		||||
 | 
			
		||||
    Fasp::AnnounceContentLifecycleEventWorker.perform_async(uri, 'update')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce_deleted_content_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless account_indexable? && public_visibility?
 | 
			
		||||
 | 
			
		||||
    Fasp::AnnounceContentLifecycleEventWorker.perform_async(uri, 'delete')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce_trends_to_subscribed_fasp
 | 
			
		||||
    return unless Mastodon::Feature.fasp_enabled?
 | 
			
		||||
    return unless account_indexable?
 | 
			
		||||
 | 
			
		||||
    candidate_id, trend_source =
 | 
			
		||||
      if reblog_of_id
 | 
			
		||||
        [reblog_of_id, 'reblog']
 | 
			
		||||
      elsif in_reply_to_id
 | 
			
		||||
        [in_reply_to_id, 'reply']
 | 
			
		||||
      end
 | 
			
		||||
    Fasp::AnnounceTrendWorker.perform_async(candidate_id, trend_source) if candidate_id
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Fasp
 | 
			
		||||
  DATA_CATEGORIES = %w(account content).freeze
 | 
			
		||||
 | 
			
		||||
  def self.table_name_prefix
 | 
			
		||||
    'fasp_'
 | 
			
		||||
  end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										67
									
								
								app/models/fasp/backfill_request.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								app/models/fasp/backfill_request.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: fasp_backfill_requests
 | 
			
		||||
#
 | 
			
		||||
#  id               :bigint(8)        not null, primary key
 | 
			
		||||
#  category         :string           not null
 | 
			
		||||
#  cursor           :string
 | 
			
		||||
#  fulfilled        :boolean          default(FALSE), not null
 | 
			
		||||
#  max_count        :integer          default(100), not null
 | 
			
		||||
#  created_at       :datetime         not null
 | 
			
		||||
#  updated_at       :datetime         not null
 | 
			
		||||
#  fasp_provider_id :bigint(8)        not null
 | 
			
		||||
#
 | 
			
		||||
class Fasp::BackfillRequest < ApplicationRecord
 | 
			
		||||
  belongs_to :fasp_provider, class_name: 'Fasp::Provider'
 | 
			
		||||
 | 
			
		||||
  validates :category, presence: true, inclusion: Fasp::DATA_CATEGORIES
 | 
			
		||||
  validates :max_count, presence: true,
 | 
			
		||||
                        numericality: { only_integer: true }
 | 
			
		||||
 | 
			
		||||
  after_commit :queue_fulfillment_job, on: :create
 | 
			
		||||
 | 
			
		||||
  def next_objects
 | 
			
		||||
    @next_objects ||= base_scope.to_a
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def next_uris
 | 
			
		||||
    next_objects.map { |o| ActivityPub::TagManager.instance.uri_for(o) }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def more_objects_available?
 | 
			
		||||
    return false if next_objects.empty?
 | 
			
		||||
 | 
			
		||||
    base_scope.where(id: ...(next_objects.last.id)).any?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def advance!
 | 
			
		||||
    if more_objects_available?
 | 
			
		||||
      update!(cursor: next_objects.last.id)
 | 
			
		||||
    else
 | 
			
		||||
      update!(fulfilled: true)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def base_scope
 | 
			
		||||
    result = category_scope.limit(max_count).order(id: :desc)
 | 
			
		||||
    result = result.where(id: ...cursor) if cursor.present?
 | 
			
		||||
    result
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def category_scope
 | 
			
		||||
    case category
 | 
			
		||||
    when 'account'
 | 
			
		||||
      Account.discoverable.without_instance_actor
 | 
			
		||||
    when 'content'
 | 
			
		||||
      Status.indexable
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def queue_fulfillment_job
 | 
			
		||||
    Fasp::BackfillWorker.perform_async(id)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -22,7 +22,9 @@
 | 
			
		||||
class Fasp::Provider < ApplicationRecord
 | 
			
		||||
  include DebugConcern
 | 
			
		||||
 | 
			
		||||
  has_many :fasp_backfill_requests, inverse_of: :fasp_provider, class_name: 'Fasp::BackfillRequest', dependent: :delete_all
 | 
			
		||||
  has_many :fasp_debug_callbacks, inverse_of: :fasp_provider, class_name: 'Fasp::DebugCallback', dependent: :delete_all
 | 
			
		||||
  has_many :fasp_subscriptions, inverse_of: :fasp_provider, class_name: 'Fasp::Subscription', dependent: :delete_all
 | 
			
		||||
 | 
			
		||||
  validates :name, presence: true
 | 
			
		||||
  validates :base_url, presence: true, url: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								app/models/fasp/subscription.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/models/fasp/subscription.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: fasp_subscriptions
 | 
			
		||||
#
 | 
			
		||||
#  id                  :bigint(8)        not null, primary key
 | 
			
		||||
#  category            :string           not null
 | 
			
		||||
#  max_batch_size      :integer          not null
 | 
			
		||||
#  subscription_type   :string           not null
 | 
			
		||||
#  threshold_likes     :integer
 | 
			
		||||
#  threshold_replies   :integer
 | 
			
		||||
#  threshold_shares    :integer
 | 
			
		||||
#  threshold_timeframe :integer
 | 
			
		||||
#  created_at          :datetime         not null
 | 
			
		||||
#  updated_at          :datetime         not null
 | 
			
		||||
#  fasp_provider_id    :bigint(8)        not null
 | 
			
		||||
#
 | 
			
		||||
class Fasp::Subscription < ApplicationRecord
 | 
			
		||||
  TYPES = %w(lifecycle trends).freeze
 | 
			
		||||
 | 
			
		||||
  belongs_to :fasp_provider, class_name: 'Fasp::Provider'
 | 
			
		||||
 | 
			
		||||
  validates :category, presence: true, inclusion: Fasp::DATA_CATEGORIES
 | 
			
		||||
  validates :subscription_type, presence: true,
 | 
			
		||||
                                inclusion: TYPES
 | 
			
		||||
 | 
			
		||||
  scope :category_content, -> { where(category: 'content') }
 | 
			
		||||
  scope :category_account, -> { where(category: 'account') }
 | 
			
		||||
  scope :lifecycle, -> { where(subscription_type: 'lifecycle') }
 | 
			
		||||
  scope :trends, -> { where(subscription_type: 'trends') }
 | 
			
		||||
 | 
			
		||||
  def threshold=(threshold)
 | 
			
		||||
    self.threshold_timeframe = threshold['timeframe'] || 15
 | 
			
		||||
    self.threshold_shares    = threshold['shares'] || 3
 | 
			
		||||
    self.threshold_likes     = threshold['likes'] || 3
 | 
			
		||||
    self.threshold_replies   = threshold['replies'] || 3
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def timeframe_start
 | 
			
		||||
    threshold_timeframe.minutes.ago
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -13,6 +13,7 @@
 | 
			
		||||
 | 
			
		||||
class Favourite < ApplicationRecord
 | 
			
		||||
  include Paginable
 | 
			
		||||
  include Favourite::FaspConcern
 | 
			
		||||
 | 
			
		||||
  update_index('statuses', :status)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,7 @@ class Status < ApplicationRecord
 | 
			
		||||
  include Discard::Model
 | 
			
		||||
  include Paginable
 | 
			
		||||
  include RateLimitable
 | 
			
		||||
  include Status::FaspConcern
 | 
			
		||||
  include Status::FetchRepliesConcern
 | 
			
		||||
  include Status::SafeReblogInsert
 | 
			
		||||
  include Status::SearchConcern
 | 
			
		||||
@@ -181,7 +182,7 @@ class Status < ApplicationRecord
 | 
			
		||||
                   ],
 | 
			
		||||
                   thread: :account
 | 
			
		||||
 | 
			
		||||
  delegate :domain, to: :account, prefix: true
 | 
			
		||||
  delegate :domain, :indexable?, to: :account, prefix: true
 | 
			
		||||
 | 
			
		||||
  REAL_TIME_WINDOW = 6.hours
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								app/workers/fasp/announce_account_lifecycle_event_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/workers/fasp/announce_account_lifecycle_event_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Fasp::AnnounceAccountLifecycleEventWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
 | 
			
		||||
  sidekiq_options queue: 'fasp', retry: 5
 | 
			
		||||
 | 
			
		||||
  def perform(uri, event_type)
 | 
			
		||||
    Fasp::Subscription.includes(:fasp_provider).category_account.lifecycle.each do |subscription|
 | 
			
		||||
      announce(subscription, uri, event_type)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce(subscription, uri, event_type)
 | 
			
		||||
    Fasp::Request.new(subscription.fasp_provider).post('/data_sharing/v0/announcements', body: {
 | 
			
		||||
      source: {
 | 
			
		||||
        subscription: {
 | 
			
		||||
          id: subscription.id.to_s,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      category: 'account',
 | 
			
		||||
      eventType: event_type,
 | 
			
		||||
      objectUris: [uri],
 | 
			
		||||
    })
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										28
									
								
								app/workers/fasp/announce_content_lifecycle_event_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/workers/fasp/announce_content_lifecycle_event_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,28 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Fasp::AnnounceContentLifecycleEventWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
 | 
			
		||||
  sidekiq_options queue: 'fasp', retry: 5
 | 
			
		||||
 | 
			
		||||
  def perform(uri, event_type)
 | 
			
		||||
    Fasp::Subscription.includes(:fasp_provider).category_content.lifecycle.each do |subscription|
 | 
			
		||||
      announce(subscription, uri, event_type)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce(subscription, uri, event_type)
 | 
			
		||||
    Fasp::Request.new(subscription.fasp_provider).post('/data_sharing/v0/announcements', body: {
 | 
			
		||||
      source: {
 | 
			
		||||
        subscription: {
 | 
			
		||||
          id: subscription.id.to_s,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      category: 'content',
 | 
			
		||||
      eventType: event_type,
 | 
			
		||||
      objectUris: [uri],
 | 
			
		||||
    })
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										61
									
								
								app/workers/fasp/announce_trend_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/workers/fasp/announce_trend_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,61 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Fasp::AnnounceTrendWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
 | 
			
		||||
  sidekiq_options queue: 'fasp', retry: 5
 | 
			
		||||
 | 
			
		||||
  def perform(status_id, trend_source)
 | 
			
		||||
    status = ::Status.includes(:account).find(status_id)
 | 
			
		||||
    return unless status.account.indexable?
 | 
			
		||||
 | 
			
		||||
    Fasp::Subscription.includes(:fasp_provider).category_content.trends.each do |subscription|
 | 
			
		||||
      announce(subscription, status.uri) if trending?(subscription, status, trend_source)
 | 
			
		||||
    end
 | 
			
		||||
  rescue ActiveRecord::RecordNotFound
 | 
			
		||||
    # status might not exist anymore, in which case there is nothing to do
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def trending?(subscription, status, trend_source)
 | 
			
		||||
    scope = scope_for(status, trend_source)
 | 
			
		||||
    threshold = threshold_for(subscription, trend_source)
 | 
			
		||||
    scope.where(created_at: subscription.timeframe_start..).count >= threshold
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def scope_for(status, trend_source)
 | 
			
		||||
    case trend_source
 | 
			
		||||
    when 'favourite'
 | 
			
		||||
      status.favourites
 | 
			
		||||
    when 'reblog'
 | 
			
		||||
      status.reblogs
 | 
			
		||||
    when 'reply'
 | 
			
		||||
      status.replies
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def threshold_for(subscription, trend_source)
 | 
			
		||||
    case trend_source
 | 
			
		||||
    when 'favourite'
 | 
			
		||||
      subscription.threshold_likes
 | 
			
		||||
    when 'reblog'
 | 
			
		||||
      subscription.threshold_shares
 | 
			
		||||
    when 'reply'
 | 
			
		||||
      subscription.threshold_replies
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def announce(subscription, uri)
 | 
			
		||||
    Fasp::Request.new(subscription.fasp_provider).post('/data_sharing/v0/announcements', body: {
 | 
			
		||||
      source: {
 | 
			
		||||
        subscription: {
 | 
			
		||||
          id: subscription.id.to_s,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      category: 'content',
 | 
			
		||||
      eventType: 'trending',
 | 
			
		||||
      objectUris: [uri],
 | 
			
		||||
    })
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										32
									
								
								app/workers/fasp/backfill_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								app/workers/fasp/backfill_worker.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Fasp::BackfillWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
 | 
			
		||||
  sidekiq_options queue: 'fasp', retry: 5
 | 
			
		||||
 | 
			
		||||
  def perform(backfill_request_id)
 | 
			
		||||
    backfill_request = Fasp::BackfillRequest.find(backfill_request_id)
 | 
			
		||||
 | 
			
		||||
    announce(backfill_request)
 | 
			
		||||
 | 
			
		||||
    backfill_request.advance!
 | 
			
		||||
  rescue ActiveRecord::RecordNotFound
 | 
			
		||||
    # ignore missing backfill requests
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def announce(backfill_request)
 | 
			
		||||
    Fasp::Request.new(backfill_request.fasp_provider).post('/data_sharing/v0/announcements', body: {
 | 
			
		||||
      source: {
 | 
			
		||||
        backfillRequest: {
 | 
			
		||||
          id: backfill_request.id.to_s,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      category: backfill_request.category,
 | 
			
		||||
      objectUris: backfill_request.next_uris,
 | 
			
		||||
      moreObjectsAvailable: backfill_request.more_objects_available?,
 | 
			
		||||
    })
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -10,6 +10,16 @@ namespace :api, format: false do
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    namespace :data_sharing do
 | 
			
		||||
      namespace :v0 do
 | 
			
		||||
        resources :backfill_requests, only: [:create] do
 | 
			
		||||
          resource :continuation, only: [:create]
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        resources :event_subscriptions, only: [:create, :destroy]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    resource :registration, only: [:create]
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@
 | 
			
		||||
  - [mailers, 2]
 | 
			
		||||
  - [pull]
 | 
			
		||||
  - [scheduler]
 | 
			
		||||
  - [fasp]
 | 
			
		||||
 | 
			
		||||
:scheduler:
 | 
			
		||||
  :listened_queues_only: true
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								db/migrate/20241213130230_create_fasp_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								db/migrate/20241213130230_create_fasp_subscriptions.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class CreateFaspSubscriptions < ActiveRecord::Migration[7.2]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :fasp_subscriptions do |t|
 | 
			
		||||
      t.string :category, null: false
 | 
			
		||||
      t.string :subscription_type, null: false
 | 
			
		||||
      t.integer :max_batch_size, null: false
 | 
			
		||||
      t.integer :threshold_timeframe
 | 
			
		||||
      t.integer :threshold_shares
 | 
			
		||||
      t.integer :threshold_likes
 | 
			
		||||
      t.integer :threshold_replies
 | 
			
		||||
      t.references :fasp_provider, null: false, foreign_key: true
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										15
									
								
								db/migrate/20250103131909_create_fasp_backfill_requests.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								db/migrate/20250103131909_create_fasp_backfill_requests.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class CreateFaspBackfillRequests < ActiveRecord::Migration[7.2]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :fasp_backfill_requests do |t|
 | 
			
		||||
      t.string :category, null: false
 | 
			
		||||
      t.integer :max_count, null: false, default: 100
 | 
			
		||||
      t.string :cursor
 | 
			
		||||
      t.boolean :fulfilled, null: false, default: false
 | 
			
		||||
      t.references :fasp_provider, null: false, foreign_key: true
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										27
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -445,6 +445,17 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do
 | 
			
		||||
    t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "fasp_backfill_requests", force: :cascade do |t|
 | 
			
		||||
    t.string "category", null: false
 | 
			
		||||
    t.integer "max_count", default: 100, null: false
 | 
			
		||||
    t.string "cursor"
 | 
			
		||||
    t.boolean "fulfilled", default: false, null: false
 | 
			
		||||
    t.bigint "fasp_provider_id", null: false
 | 
			
		||||
    t.datetime "created_at", null: false
 | 
			
		||||
    t.datetime "updated_at", null: false
 | 
			
		||||
    t.index ["fasp_provider_id"], name: "index_fasp_backfill_requests_on_fasp_provider_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "fasp_debug_callbacks", force: :cascade do |t|
 | 
			
		||||
    t.bigint "fasp_provider_id", null: false
 | 
			
		||||
    t.string "ip", null: false
 | 
			
		||||
@@ -471,6 +482,20 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do
 | 
			
		||||
    t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "fasp_subscriptions", force: :cascade do |t|
 | 
			
		||||
    t.string "category", null: false
 | 
			
		||||
    t.string "subscription_type", null: false
 | 
			
		||||
    t.integer "max_batch_size", null: false
 | 
			
		||||
    t.integer "threshold_timeframe"
 | 
			
		||||
    t.integer "threshold_shares"
 | 
			
		||||
    t.integer "threshold_likes"
 | 
			
		||||
    t.integer "threshold_replies"
 | 
			
		||||
    t.bigint "fasp_provider_id", null: false
 | 
			
		||||
    t.datetime "created_at", null: false
 | 
			
		||||
    t.datetime "updated_at", null: false
 | 
			
		||||
    t.index ["fasp_provider_id"], name: "index_fasp_subscriptions_on_fasp_provider_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "favourites", force: :cascade do |t|
 | 
			
		||||
    t.datetime "created_at", precision: nil, null: false
 | 
			
		||||
    t.datetime "updated_at", precision: nil, null: false
 | 
			
		||||
@@ -1322,7 +1347,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_28_095029) do
 | 
			
		||||
  add_foreign_key "custom_filter_statuses", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "custom_filters", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "email_domain_blocks", "email_domain_blocks", column: "parent_id", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "fasp_backfill_requests", "fasp_providers"
 | 
			
		||||
  add_foreign_key "fasp_debug_callbacks", "fasp_providers"
 | 
			
		||||
  add_foreign_key "fasp_subscriptions", "fasp_providers"
 | 
			
		||||
  add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "featured_tags", "accounts", on_delete: :cascade
 | 
			
		||||
 
 | 
			
		||||
@@ -15,4 +15,5 @@ Fabricator(:account) do
 | 
			
		||||
  user                { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil }
 | 
			
		||||
  uri                 { |attrs| attrs[:domain].nil? ? '' : "https://#{attrs[:domain]}/users/#{attrs[:username]}" }
 | 
			
		||||
  discoverable        true
 | 
			
		||||
  indexable           true
 | 
			
		||||
end
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								spec/fabricators/fasp/backfill_request_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								spec/fabricators/fasp/backfill_request_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Fabricator(:fasp_backfill_request, from: 'Fasp::BackfillRequest') do
 | 
			
		||||
  category      'content'
 | 
			
		||||
  max_count     10
 | 
			
		||||
  cursor        nil
 | 
			
		||||
  fulfilled     false
 | 
			
		||||
  fasp_provider
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										8
									
								
								spec/fabricators/fasp/subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								spec/fabricators/fasp/subscription_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Fabricator(:fasp_subscription, from: 'Fasp::Subscription') do
 | 
			
		||||
  category            'content'
 | 
			
		||||
  subscription_type   'lifecycle'
 | 
			
		||||
  max_batch_size      10
 | 
			
		||||
  fasp_provider
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										83
									
								
								spec/models/concerns/account/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								spec/models/concerns/account/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,83 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Account::FaspConcern, feature: :fasp do
 | 
			
		||||
  describe '#create' do
 | 
			
		||||
    let(:discoverable_attributes) do
 | 
			
		||||
      Fabricate.attributes_for(:account).except('user_id')
 | 
			
		||||
    end
 | 
			
		||||
    let(:undiscoverable_attributes) do
 | 
			
		||||
      discoverable_attributes.merge('discoverable' => false)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is discoverable' do
 | 
			
		||||
      it 'queues a job to notify provider' do
 | 
			
		||||
        Account.create(discoverable_attributes)
 | 
			
		||||
 | 
			
		||||
        expect(Fasp::AnnounceAccountLifecycleEventWorker).to have_enqueued_sidekiq_job
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is not discoverable' do
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        Account.create(undiscoverable_attributes)
 | 
			
		||||
 | 
			
		||||
        expect(Fasp::AnnounceAccountLifecycleEventWorker).to_not have_enqueued_sidekiq_job
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#update' do
 | 
			
		||||
    before do
 | 
			
		||||
      # Create account and clear sidekiq queue so we only catch
 | 
			
		||||
      # jobs queued as part of the update
 | 
			
		||||
      account
 | 
			
		||||
      Sidekiq::Worker.clear_all
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is discoverable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
 | 
			
		||||
      it 'queues a job to notify provider' do
 | 
			
		||||
        expect { account.touch }.to enqueue_sidekiq_job(Fasp::AnnounceAccountLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account was discoverable before' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
 | 
			
		||||
      it 'queues a job to notify provider' do
 | 
			
		||||
        expect do
 | 
			
		||||
          account.update(discoverable: false)
 | 
			
		||||
        end.to enqueue_sidekiq_job(Fasp::AnnounceAccountLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account has not been discoverable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com', discoverable: false) }
 | 
			
		||||
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        expect { account.touch }.to_not enqueue_sidekiq_job(Fasp::AnnounceAccountLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#destroy' do
 | 
			
		||||
    context 'when account is discoverable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
 | 
			
		||||
      it 'queues a job to notify provider' do
 | 
			
		||||
        expect { account.destroy }.to enqueue_sidekiq_job(Fasp::AnnounceAccountLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is not discoverable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com', discoverable: false) }
 | 
			
		||||
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        expect { account.destroy }.to_not enqueue_sidekiq_job(Fasp::AnnounceAccountLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										11
									
								
								spec/models/concerns/favourite/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								spec/models/concerns/favourite/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,11 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Favourite::FaspConcern, feature: :fasp do
 | 
			
		||||
  describe '#create' do
 | 
			
		||||
    it 'queues a job to notify provider' do
 | 
			
		||||
      expect { Fabricate(:favourite) }.to enqueue_sidekiq_job(Fasp::AnnounceTrendWorker)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										123
									
								
								spec/models/concerns/status/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								spec/models/concerns/status/fasp_concern_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,123 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Status::FaspConcern, feature: :fasp do
 | 
			
		||||
  describe '#create' do
 | 
			
		||||
    context 'when account is indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
 | 
			
		||||
      context 'when status is public' do
 | 
			
		||||
        it 'queues a job to notify provider of new status' do
 | 
			
		||||
          expect do
 | 
			
		||||
            Fabricate(:status, account:)
 | 
			
		||||
          end.to enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when status is not public' do
 | 
			
		||||
        it 'does not queue a job' do
 | 
			
		||||
          expect do
 | 
			
		||||
            Fabricate(:status, account:, visibility: :unlisted)
 | 
			
		||||
          end.to_not enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when status is in reply to another' do
 | 
			
		||||
        it 'queues a job to notify provider of possible trend' do
 | 
			
		||||
          parent = Fabricate(:status)
 | 
			
		||||
          expect do
 | 
			
		||||
            Fabricate(:status, account:, thread: parent)
 | 
			
		||||
          end.to enqueue_sidekiq_job(Fasp::AnnounceTrendWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when status is a reblog of another' do
 | 
			
		||||
        it 'queues a job to notify provider of possible trend' do
 | 
			
		||||
          original = Fabricate(:status, account:)
 | 
			
		||||
          expect do
 | 
			
		||||
            Fabricate(:status, account:, reblog: original)
 | 
			
		||||
          end.to enqueue_sidekiq_job(Fasp::AnnounceTrendWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is not indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, indexable: false) }
 | 
			
		||||
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        expect do
 | 
			
		||||
          Fabricate(:status, account:)
 | 
			
		||||
        end.to_not enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#update' do
 | 
			
		||||
    before do
 | 
			
		||||
      # Create status and clear sidekiq queues to only catch
 | 
			
		||||
      # jobs queued due to the update
 | 
			
		||||
      status
 | 
			
		||||
      Sidekiq::Worker.clear_all
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
      let(:status) { Fabricate(:status, account:, visibility:) }
 | 
			
		||||
 | 
			
		||||
      context 'when status is public' do
 | 
			
		||||
        let(:visibility) { :public }
 | 
			
		||||
 | 
			
		||||
        it 'queues a job to notify provider' do
 | 
			
		||||
          expect { status.touch }.to enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when status has not been public' do
 | 
			
		||||
        let(:visibility) { :unlisted }
 | 
			
		||||
 | 
			
		||||
        it 'does not queue a job' do
 | 
			
		||||
          expect do
 | 
			
		||||
            status.touch
 | 
			
		||||
          end.to_not enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is not indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com', indexable: false) }
 | 
			
		||||
      let(:status) { Fabricate(:status, account:) }
 | 
			
		||||
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        expect { status.touch }.to_not enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#destroy' do
 | 
			
		||||
    let(:status) { Fabricate(:status, account:) }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      # Create status and clear sidekiq queues to only catch
 | 
			
		||||
      # jobs queued due to the update
 | 
			
		||||
      status
 | 
			
		||||
      Sidekiq::Worker.clear_all
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com') }
 | 
			
		||||
 | 
			
		||||
      it 'queues a job to notify provider' do
 | 
			
		||||
        expect { status.destroy }.to enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when account is not indexable' do
 | 
			
		||||
      let(:account) { Fabricate(:account, domain: 'example.com', indexable: false) }
 | 
			
		||||
 | 
			
		||||
      it 'does not queue a job' do
 | 
			
		||||
        expect { status.destroy }.to_not enqueue_sidekiq_job(Fasp::AnnounceContentLifecycleEventWorker)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										93
									
								
								spec/models/fasp/backfill_request_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								spec/models/fasp/backfill_request_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,93 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::BackfillRequest do
 | 
			
		||||
  describe '#next_objects' do
 | 
			
		||||
    let(:account) { Fabricate(:account) }
 | 
			
		||||
    let!(:statuses) { Fabricate.times(3, :status, account:).sort_by(&:id) }
 | 
			
		||||
 | 
			
		||||
    context 'with a new backfill request' do
 | 
			
		||||
      subject { Fabricate(:fasp_backfill_request, max_count: 2) }
 | 
			
		||||
 | 
			
		||||
      it 'returns the newest two statuses' do
 | 
			
		||||
        expect(subject.next_objects).to eq [statuses[2], statuses[1]]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with cursor set to second newest status' do
 | 
			
		||||
      subject do
 | 
			
		||||
        Fabricate(:fasp_backfill_request, max_count: 2, cursor: statuses[1].id)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'returns the oldest status' do
 | 
			
		||||
        expect(subject.next_objects).to eq [statuses[0]]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when all statuses are not `indexable`' do
 | 
			
		||||
      subject { Fabricate(:fasp_backfill_request) }
 | 
			
		||||
 | 
			
		||||
      let(:account) { Fabricate(:account, indexable: false) }
 | 
			
		||||
 | 
			
		||||
      it 'returns no statuses' do
 | 
			
		||||
        expect(subject.next_objects).to be_empty
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#next_uris' do
 | 
			
		||||
    subject { Fabricate(:fasp_backfill_request) }
 | 
			
		||||
 | 
			
		||||
    let(:statuses) { Fabricate.times(2, :status) }
 | 
			
		||||
 | 
			
		||||
    it 'returns uris of the next objects' do
 | 
			
		||||
      uris = statuses.map(&:uri)
 | 
			
		||||
 | 
			
		||||
      expect(subject.next_uris).to match_array(uris)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#more_objects_available?' do
 | 
			
		||||
    subject { Fabricate(:fasp_backfill_request, max_count: 2) }
 | 
			
		||||
 | 
			
		||||
    context 'when more objects are available' do
 | 
			
		||||
      before { Fabricate.times(3, :status) }
 | 
			
		||||
 | 
			
		||||
      it 'returns `true`' do
 | 
			
		||||
        expect(subject.more_objects_available?).to be true
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when no more objects are available' do
 | 
			
		||||
      before { Fabricate.times(2, :status) }
 | 
			
		||||
 | 
			
		||||
      it 'returns `false`' do
 | 
			
		||||
        expect(subject.more_objects_available?).to be false
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#advance!' do
 | 
			
		||||
    subject { Fabricate(:fasp_backfill_request, max_count: 2) }
 | 
			
		||||
 | 
			
		||||
    context 'when more objects are available' do
 | 
			
		||||
      before { Fabricate.times(3, :status) }
 | 
			
		||||
 | 
			
		||||
      it 'updates `cursor`' do
 | 
			
		||||
        expect { subject.advance! }.to change(subject, :cursor)
 | 
			
		||||
        expect(subject).to be_persisted
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when no more objects are available' do
 | 
			
		||||
      before { Fabricate.times(2, :status) }
 | 
			
		||||
 | 
			
		||||
      it 'sets `fulfilled` to `true`' do
 | 
			
		||||
        expect { subject.advance! }.to change(subject, :fulfilled)
 | 
			
		||||
          .from(false).to(true)
 | 
			
		||||
        expect(subject).to be_persisted
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										33
									
								
								spec/models/fasp/subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								spec/models/fasp/subscription_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::Subscription do
 | 
			
		||||
  describe '#threshold=' do
 | 
			
		||||
    subject { described_class.new }
 | 
			
		||||
 | 
			
		||||
    it 'allows setting all threshold values at once' do
 | 
			
		||||
      subject.threshold = {
 | 
			
		||||
        'timeframe' => 30,
 | 
			
		||||
        'shares' => 5,
 | 
			
		||||
        'likes' => 8,
 | 
			
		||||
        'replies' => 7,
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      expect(subject.threshold_timeframe).to eq 30
 | 
			
		||||
      expect(subject.threshold_shares).to eq 5
 | 
			
		||||
      expect(subject.threshold_likes).to eq 8
 | 
			
		||||
      expect(subject.threshold_replies).to eq 7
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#timeframe_start' do
 | 
			
		||||
    subject { described_class.new(threshold_timeframe: 45) }
 | 
			
		||||
 | 
			
		||||
    it 'returns a Time representing the beginning of the timeframe' do
 | 
			
		||||
      travel_to Time.zone.local(2025, 4, 7, 16, 40) do
 | 
			
		||||
        expect(subject.timeframe_start).to eq Time.zone.local(2025, 4, 7, 15, 55)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,41 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe 'Api::Fasp::DataSharing::V0::BackfillRequests', feature: :fasp do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  describe 'POST /api/fasp/data_sharing/v0/backfill_requests' do
 | 
			
		||||
    let(:provider) { Fabricate(:fasp_provider) }
 | 
			
		||||
 | 
			
		||||
    context 'with valid parameters' do
 | 
			
		||||
      it 'creates a new backfill request' do
 | 
			
		||||
        params = { category: 'content', maxCount: 10 }
 | 
			
		||||
        headers = request_authentication_headers(provider,
 | 
			
		||||
                                                 url: api_fasp_data_sharing_v0_backfill_requests_url,
 | 
			
		||||
                                                 method: :post,
 | 
			
		||||
                                                 body: params)
 | 
			
		||||
 | 
			
		||||
        expect do
 | 
			
		||||
          post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json
 | 
			
		||||
        end.to change(Fasp::BackfillRequest, :count).by(1)
 | 
			
		||||
        expect(response).to have_http_status(201)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with invalid parameters' do
 | 
			
		||||
      it 'does not create a backfill request' do
 | 
			
		||||
        params = { category: 'unknown', maxCount: 10 }
 | 
			
		||||
        headers = request_authentication_headers(provider,
 | 
			
		||||
                                                 url: api_fasp_data_sharing_v0_backfill_requests_url,
 | 
			
		||||
                                                 method: :post,
 | 
			
		||||
                                                 body: params)
 | 
			
		||||
 | 
			
		||||
        expect do
 | 
			
		||||
          post api_fasp_data_sharing_v0_backfill_requests_path, headers:, params:, as: :json
 | 
			
		||||
        end.to_not change(Fasp::BackfillRequest, :count)
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										22
									
								
								spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								spec/requests/api/fasp/data_sharing/v0/continuations_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe 'Api::Fasp::DataSharing::V0::Continuations', feature: :fasp do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  describe 'POST /api/fasp/data_sharing/v0/backfill_requests/:id/continuations' do
 | 
			
		||||
    let(:backfill_request) { Fabricate(:fasp_backfill_request) }
 | 
			
		||||
    let(:provider) { backfill_request.fasp_provider }
 | 
			
		||||
 | 
			
		||||
    it 'queues a job to continue the given backfill request' do
 | 
			
		||||
      headers = request_authentication_headers(provider,
 | 
			
		||||
                                               url: api_fasp_data_sharing_v0_backfill_request_continuation_url(backfill_request),
 | 
			
		||||
                                               method: :post)
 | 
			
		||||
 | 
			
		||||
      post api_fasp_data_sharing_v0_backfill_request_continuation_path(backfill_request), headers:, as: :json
 | 
			
		||||
      expect(response).to have_http_status(204)
 | 
			
		||||
      expect(Fasp::BackfillWorker).to have_enqueued_sidekiq_job(backfill_request.id)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,57 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe 'Api::Fasp::DataSharing::V0::EventSubscriptions', feature: :fasp do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  describe 'POST /api/fasp/data_sharing/v0/event_subscriptions' do
 | 
			
		||||
    let(:provider) { Fabricate(:fasp_provider) }
 | 
			
		||||
 | 
			
		||||
    context 'with valid parameters' do
 | 
			
		||||
      it 'creates a new subscription' do
 | 
			
		||||
        params = { category: 'content', subscriptionType: 'lifecycle', maxBatchSize: 10 }
 | 
			
		||||
        headers = request_authentication_headers(provider,
 | 
			
		||||
                                                 url: api_fasp_data_sharing_v0_event_subscriptions_url,
 | 
			
		||||
                                                 method: :post,
 | 
			
		||||
                                                 body: params)
 | 
			
		||||
 | 
			
		||||
        expect do
 | 
			
		||||
          post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json
 | 
			
		||||
        end.to change(Fasp::Subscription, :count).by(1)
 | 
			
		||||
        expect(response).to have_http_status(201)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with invalid parameters' do
 | 
			
		||||
      it 'does not create a subscription' do
 | 
			
		||||
        params = { category: 'unknown' }
 | 
			
		||||
        headers = request_authentication_headers(provider,
 | 
			
		||||
                                                 url: api_fasp_data_sharing_v0_event_subscriptions_url,
 | 
			
		||||
                                                 method: :post,
 | 
			
		||||
                                                 body: params)
 | 
			
		||||
 | 
			
		||||
        expect do
 | 
			
		||||
          post api_fasp_data_sharing_v0_event_subscriptions_path, headers:, params:, as: :json
 | 
			
		||||
        end.to_not change(Fasp::Subscription, :count)
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'DELETE /api/fasp/data_sharing/v0/event_subscriptions/:id' do
 | 
			
		||||
    let(:subscription) { Fabricate(:fasp_subscription) }
 | 
			
		||||
    let(:provider) { subscription.fasp_provider }
 | 
			
		||||
 | 
			
		||||
    it 'deletes the subscription' do
 | 
			
		||||
      headers = request_authentication_headers(provider,
 | 
			
		||||
                                               url: api_fasp_data_sharing_v0_event_subscription_url(subscription),
 | 
			
		||||
                                               method: :delete)
 | 
			
		||||
 | 
			
		||||
      expect do
 | 
			
		||||
        delete api_fasp_data_sharing_v0_event_subscription_path(subscription), headers:, as: :json
 | 
			
		||||
      end.to change(Fasp::Subscription, :count).by(-1)
 | 
			
		||||
      expect(response).to have_http_status(204)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,34 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::AnnounceAccountLifecycleEventWorker do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  let(:account_uri) { 'https://masto.example.com/accounts/1' }
 | 
			
		||||
  let(:subscription) do
 | 
			
		||||
    Fabricate(:fasp_subscription, category: 'account')
 | 
			
		||||
  end
 | 
			
		||||
  let(:provider) { subscription.fasp_provider }
 | 
			
		||||
  let!(:stubbed_request) do
 | 
			
		||||
    stub_provider_request(provider,
 | 
			
		||||
                          method: :post,
 | 
			
		||||
                          path: '/data_sharing/v0/announcements',
 | 
			
		||||
                          response_body: {
 | 
			
		||||
                            source: {
 | 
			
		||||
                              subscription: {
 | 
			
		||||
                                id: subscription.id.to_s,
 | 
			
		||||
                              },
 | 
			
		||||
                            },
 | 
			
		||||
                            category: 'account',
 | 
			
		||||
                            eventType: 'new',
 | 
			
		||||
                            objectUris: [account_uri],
 | 
			
		||||
                          })
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  it 'sends the account uri to subscribed providers' do
 | 
			
		||||
    described_class.new.perform(account_uri, 'new')
 | 
			
		||||
 | 
			
		||||
    expect(stubbed_request).to have_been_made
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
@@ -0,0 +1,34 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::AnnounceContentLifecycleEventWorker do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  let(:status_uri) { 'https://masto.example.com/status/1' }
 | 
			
		||||
  let(:subscription) do
 | 
			
		||||
    Fabricate(:fasp_subscription)
 | 
			
		||||
  end
 | 
			
		||||
  let(:provider) { subscription.fasp_provider }
 | 
			
		||||
  let!(:stubbed_request) do
 | 
			
		||||
    stub_provider_request(provider,
 | 
			
		||||
                          method: :post,
 | 
			
		||||
                          path: '/data_sharing/v0/announcements',
 | 
			
		||||
                          response_body: {
 | 
			
		||||
                            source: {
 | 
			
		||||
                              subscription: {
 | 
			
		||||
                                id: subscription.id.to_s,
 | 
			
		||||
                              },
 | 
			
		||||
                            },
 | 
			
		||||
                            category: 'content',
 | 
			
		||||
                            eventType: 'new',
 | 
			
		||||
                            objectUris: [status_uri],
 | 
			
		||||
                          })
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  it 'sends the status uri to subscribed providers' do
 | 
			
		||||
    described_class.new.perform(status_uri, 'new')
 | 
			
		||||
 | 
			
		||||
    expect(stubbed_request).to have_been_made
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										52
									
								
								spec/workers/fasp/announce_trend_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								spec/workers/fasp/announce_trend_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::AnnounceTrendWorker do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  let(:status) { Fabricate(:status) }
 | 
			
		||||
  let(:subscription) do
 | 
			
		||||
    Fabricate(:fasp_subscription,
 | 
			
		||||
              category: 'content',
 | 
			
		||||
              subscription_type: 'trends',
 | 
			
		||||
              threshold_timeframe: 15,
 | 
			
		||||
              threshold_likes: 2)
 | 
			
		||||
  end
 | 
			
		||||
  let(:provider) { subscription.fasp_provider }
 | 
			
		||||
  let!(:stubbed_request) do
 | 
			
		||||
    stub_provider_request(provider,
 | 
			
		||||
                          method: :post,
 | 
			
		||||
                          path: '/data_sharing/v0/announcements',
 | 
			
		||||
                          response_body: {
 | 
			
		||||
                            source: {
 | 
			
		||||
                              subscription: {
 | 
			
		||||
                                id: subscription.id.to_s,
 | 
			
		||||
                              },
 | 
			
		||||
                            },
 | 
			
		||||
                            category: 'content',
 | 
			
		||||
                            eventType: 'trending',
 | 
			
		||||
                            objectUris: [status.uri],
 | 
			
		||||
                          })
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'when the configured threshold is met' do
 | 
			
		||||
    before do
 | 
			
		||||
      Fabricate.times(2, :favourite, status:)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'sends the account uri to subscribed providers' do
 | 
			
		||||
      described_class.new.perform(status.id, 'favourite')
 | 
			
		||||
 | 
			
		||||
      expect(stubbed_request).to have_been_made
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'when the configured threshold is not met' do
 | 
			
		||||
    it 'does not notify any provider' do
 | 
			
		||||
      described_class.new.perform(status.id, 'favourite')
 | 
			
		||||
 | 
			
		||||
      expect(stubbed_request).to_not have_been_made
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										32
									
								
								spec/workers/fasp/backfill_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								spec/workers/fasp/backfill_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Fasp::BackfillWorker do
 | 
			
		||||
  include ProviderRequestHelper
 | 
			
		||||
 | 
			
		||||
  let(:backfill_request) { Fabricate(:fasp_backfill_request) }
 | 
			
		||||
  let(:provider) { backfill_request.fasp_provider }
 | 
			
		||||
  let(:status) { Fabricate(:status) }
 | 
			
		||||
  let!(:stubbed_request) do
 | 
			
		||||
    stub_provider_request(provider,
 | 
			
		||||
                          method: :post,
 | 
			
		||||
                          path: '/data_sharing/v0/announcements',
 | 
			
		||||
                          response_body: {
 | 
			
		||||
                            source: {
 | 
			
		||||
                              backfillRequest: {
 | 
			
		||||
                                id: backfill_request.id.to_s,
 | 
			
		||||
                              },
 | 
			
		||||
                            },
 | 
			
		||||
                            category: 'content',
 | 
			
		||||
                            objectUris: [status.uri],
 | 
			
		||||
                            moreObjectsAvailable: false,
 | 
			
		||||
                          })
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  it 'sends status uri to provider that requested backfill' do
 | 
			
		||||
    described_class.new.perform(backfill_request.id)
 | 
			
		||||
 | 
			
		||||
    expect(stubbed_request).to have_been_made
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
		Reference in New Issue
	
	Block a user