Compare commits

...

17 Commits

Author SHA1 Message Date
David Roetzel
e00fe2dab7
Merge 8968d3a410 into 8b34daf254 2025-05-03 11:04:45 +00:00
Jonny Saunders
8b34daf254
Fix: Use strings not symbols to access totalItems in interaction collections (#34594)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / Libvips tests (.ruby-version) (push) Has been cancelled
Ruby Testing / Libvips tests (3.2) (push) Has been cancelled
Ruby Testing / Libvips tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Bundler Audit / security (push) Has been cancelled
2025-05-03 10:37:06 +00:00
David Roetzel
8968d3a410
Improve comment to explain redundant call 2025-04-10 15:03:09 +02:00
David Roetzel
95f02467c9
Add worker specs 2025-04-10 12:30:19 +02:00
David Roetzel
77488e1dab
Fix broken specs
Rubocop fooled me into breaking this.
2025-04-10 11:06:29 +02:00
David Roetzel
f106686f47
Add specs for model hooks 2025-04-10 11:05:06 +02:00
David Roetzel
ddceb88367
Add request specs 2025-04-08 16:43:13 +02:00
David Roetzel
cee1378231
Add model specs 2025-04-08 15:03:54 +02:00
David Roetzel
c94e298746
Put FASP support behind a feature flag 2025-04-08 15:03:47 +02:00
David Roetzel
716dec82bc
Fix autogenerated fabricators 2025-04-08 15:03:38 +02:00
David Roetzel
1490d3939e
Implement backfill requests 2025-04-08 15:03:31 +02:00
David Roetzel
1ad4700884
Only share public statuses.
Only actually public, i.e. not "unlisted". One exception: Share
an update if the update changed the visibility from "public" to
something else. The fasp can act on this information then.
2025-04-08 15:03:23 +02:00
David Roetzel
a09ce4f6d8
Fix typo 2025-04-08 15:03:11 +02:00
David Roetzel
69cc06bf5b
Select what to share with fasp
Only share statuses where the account has `#indexable` set
to `true`.

Only share accounts where `#discoverable` is set to `true`, with
one exception: If `#discoverable` has just been set to `false`
this is an important information for the fasp.
2025-04-08 15:02:54 +02:00
David Roetzel
75266ec2c1
Add account and trend data sharing 2025-04-08 15:02:47 +02:00
David Roetzel
efc18aee68
Handle full content lifecycle for subscribed fasps 2025-04-08 15:02:40 +02:00
David Roetzel
28dc6e5f09
Humble beginnings of fasp data sharing.
Subscriptions can be made and new statuses will be announced to
subscribed fasp.
2025-04-08 15:02:31 +02:00
39 changed files with 1156 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -95,11 +95,11 @@ class ActivityPub::Parser::StatusParser
end end
def favourites_count def favourites_count
@object.dig(:likes, :totalItems) @object.dig('likes', 'totalItems')
end end
def reblogs_count def reblogs_count
@object.dig(:shares, :totalItems) @object.dig('shares', 'totalItems')
end end
def quote_policy def quote_policy

View File

@ -32,6 +32,7 @@ class Fasp::Request
def request_headers(verb, url, body = '') def request_headers(verb, url, body = '')
result = { result = {
'accept' => 'application/json', 'accept' => 'application/json',
'content-type' => 'application/json',
'content-digest' => content_digest(body), 'content-digest' => content_digest(body),
} }
result.merge(signature_headers(verb, url, result)) result.merge(signature_headers(verb, url, result))

View File

@ -85,6 +85,7 @@ class Account < ApplicationRecord
include Account::Associations include Account::Associations
include Account::Avatar include Account::Avatar
include Account::Counters include Account::Counters
include Account::FaspConcern
include Account::FinderConcern include Account::FinderConcern
include Account::Header include Account::Header
include Account::Interactions include Account::Interactions

View 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

View 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

View File

@ -0,0 +1,57 @@
# 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_or_just_changed?
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
def public_visibility_or_just_changed?
public_visibility? || visibility_previously_was == 'public'
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Fasp module Fasp
DATA_CATEGORIES = %w(account content).freeze
def self.table_name_prefix def self.table_name_prefix
'fasp_' 'fasp_'
end end

View 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

View File

@ -22,7 +22,9 @@
class Fasp::Provider < ApplicationRecord class Fasp::Provider < ApplicationRecord
include DebugConcern 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_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 :name, presence: true
validates :base_url, presence: true, url: true validates :base_url, presence: true, url: true

View 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 :content, -> { where(category: 'content') }
scope :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

View File

@ -13,6 +13,7 @@
class Favourite < ApplicationRecord class Favourite < ApplicationRecord
include Paginable include Paginable
include Favourite::FaspConcern
update_index('statuses', :status) update_index('statuses', :status)

View File

@ -36,6 +36,7 @@ class Status < ApplicationRecord
include Discard::Model include Discard::Model
include Paginable include Paginable
include RateLimitable include RateLimitable
include Status::FaspConcern
include Status::FetchRepliesConcern include Status::FetchRepliesConcern
include Status::SafeReblogInsert include Status::SafeReblogInsert
include Status::SearchConcern include Status::SearchConcern
@ -181,7 +182,7 @@ class Status < ApplicationRecord
], ],
thread: :account thread: :account
delegate :domain, to: :account, prefix: true delegate :domain, :indexable?, to: :account, prefix: true
REAL_TIME_WINDOW = 6.hours REAL_TIME_WINDOW = 6.hours

View 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).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

View 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).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

View 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).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

View 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

View File

@ -10,6 +10,16 @@ namespace :api, format: false do
end end
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] resource :registration, only: [:create]
end end
end end

View File

@ -7,6 +7,7 @@
- [mailers, 2] - [mailers, 2]
- [pull] - [pull]
- [scheduler] - [scheduler]
- [fasp]
:scheduler: :scheduler:
:listened_queues_only: true :listened_queues_only: true

View 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

View 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

View File

@ -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 t.index ["domain"], name: "index_email_domain_blocks_on_domain", unique: true
end 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| create_table "fasp_debug_callbacks", force: :cascade do |t|
t.bigint "fasp_provider_id", null: false t.bigint "fasp_provider_id", null: false
t.string "ip", 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 t.index ["base_url"], name: "index_fasp_providers_on_base_url", unique: true
end 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| create_table "favourites", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "updated_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_filter_statuses", "statuses", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", 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 "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_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", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
add_foreign_key "featured_tags", "accounts", on_delete: :cascade add_foreign_key "featured_tags", "accounts", on_delete: :cascade

View File

@ -15,4 +15,5 @@ Fabricator(:account) do
user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil } user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil }
uri { |attrs| attrs[:domain].nil? ? '' : "https://#{attrs[:domain]}/users/#{attrs[:username]}" } uri { |attrs| attrs[:domain].nil? ? '' : "https://#{attrs[:domain]}/users/#{attrs[:username]}" }
discoverable true discoverable true
indexable true
end end

View 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

View 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

View 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

View 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

View File

@ -0,0 +1,133 @@
# 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 just switched to non-public' do
let(:visibility) { :public }
it 'queues a job to notify provider' do
expect do
status.update(visibility: :unlisted)
end.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

View 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

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View 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