Refactor activity serialization (#37678)

This commit is contained in:
David Roetzel 2026-02-05 10:39:27 +01:00 committed by GitHub
parent 8ebe2e673e
commit 73206856c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 582 additions and 477 deletions

View File

@ -26,7 +26,7 @@ class Api::V1::Statuses::PinsController < Api::V1::Statuses::BaseController
def distribute_add_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::AddSerializer,
serializer: ActivityPub::AddNoteSerializer,
adapter: ActivityPub::Adapter
).as_json
@ -36,7 +36,7 @@ class Api::V1::Statuses::PinsController < Api::V1::Statuses::BaseController
def distribute_remove_activity!
json = ActiveModelSerializers::SerializableResource.new(
@status,
serializer: ActivityPub::RemoveSerializer,
serializer: ActivityPub::RemoveNoteSerializer,
adapter: ActivityPub::Adapter
).as_json

View File

@ -37,7 +37,7 @@ class StatusesController < ApplicationController
def activity
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
render_with_cache json: @status, content_type: 'application/activity+json', serializer: activity_serializer, adapter: ActivityPub::Adapter
end
def embed
@ -69,4 +69,8 @@ class StatusesController < ApplicationController
def redirect_to_original
redirect_to(ActivityPub::TagManager.instance.url_for(@status.reblog), allow_other_host: true) if @status.reblog?
end
def activity_serializer
@status.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer
end
end

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
attributes :id, :type, :actor, :published, :to, :cc, :virtual_object
class << self
def from_status(status, allow_inlining: true)
new.tap do |presenter|
presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status)
presenter.type = status.reblog? ? 'Announce' : 'Create'
presenter.actor = ActivityPub::TagManager.instance.uri_for(status.account)
presenter.published = status.created_at
presenter.to = ActivityPub::TagManager.instance.to(status)
presenter.cc = ActivityPub::TagManager.instance.cc(status)
presenter.virtual_object = begin
if status.reblog?
if allow_inlining && status.account == status.proper.account && status.proper.private_visibility? && status.local?
status.proper
else
ActivityPub::TagManager.instance.uri_for(status.proper)
end
else
status.proper
end
end
end
end
end
end

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
class ActivityPub::ActivitySerializer < ActivityPub::Serializer
def self.serializer_for(model, options)
case model.class.name
when 'Status'
ActivityPub::NoteSerializer
else
super
end
end
attributes :id, :type, :actor, :published, :to, :cc
has_one :virtual_object, key: :object
def published
object.published.iso8601
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class ActivityPub::AddFeaturedCollectionSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :type, :actor, :target
has_one :object, serializer: ActivityPub::FeaturedCollectionSerializer
def type
'Add'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def target
ap_account_featured_collections_url(object.account_id)
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class ActivityPub::AddHashtagSerializer < ActivityPub::Serializer
attributes :type, :actor, :target
has_one :object, serializer: ActivityPub::HashtagSerializer
def type
'Add'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def target
# Technically this is not correct, as tags have their own collection.
# But sadly we do not store the collection URI for tags anywhere so cannot
# handle `Add` activities to that properly (yet). The receiving code for
# this currently looks at the type of the contained objects to do the
# right thing.
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class ActivityPub::AddNoteSerializer < ActivityPub::Serializer
attributes :type, :actor, :target
has_one :proper_object, key: :object
def type
'Add'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def proper_object
ActivityPub::TagManager.instance.uri_for(object)
end
def target
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
end
end

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
class ActivityPub::AddSerializer < ActivityPub::Serializer
class UriSerializer < ActiveModel::Serializer
include RoutingHelper
def serializable_hash(*_args)
ActivityPub::TagManager.instance.uri_for(object)
end
end
def self.serializer_for(model, options)
case model
when Status
UriSerializer
when FeaturedTag
ActivityPub::HashtagSerializer
when Collection
ActivityPub::FeaturedCollectionSerializer
else
super
end
end
include RoutingHelper
attributes :type, :actor, :target
has_one :proper_object, key: :object
def type
'Add'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def proper_object
object
end
def target
case object
when Status, FeaturedTag
# Technically this is not correct, as tags have their own collection.
# But sadly we do not store the collection URI for tags anywhere so cannot
# handle `Add` activities to that properly (yet). The receiving code for
# this currently looks at the type of the contained objects to do the
# right thing.
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
when Collection
ap_account_featured_collections_url(object.account_id)
end
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
class ActivityPub::AnnounceNoteSerializer < ActivityPub::Serializer
def self.serializer_for(model, options)
return ActivityPub::NoteSerializer if model.is_a?(Status)
super
end
attributes :id, :type, :actor, :published, :to, :cc
has_one :virtual_object, key: :object
def id
ActivityPub::TagManager.instance.activity_uri_for(object)
end
def type
'Announce'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
def published
object.created_at.iso8601
end
def virtual_object
if allow_inlining? && object.account == object.proper.account && object.proper.private_visibility? && object.local?
object.proper
else
ActivityPub::TagManager.instance.uri_for(object.proper)
end
end
private
def allow_inlining?
return instance_options[:allow_inlining] if instance_options.key?(:allow_inlining)
true
end
end

View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
class ActivityPub::CreateNoteSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :published, :to, :cc
has_one :object, serializer: ActivityPub::NoteSerializer
def id
ActivityPub::TagManager.instance.activity_uri_for(object)
end
def type
'Create'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
def published
object.created_at.iso8601
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ActivityPub::DeleteSerializer < ActivityPub::Serializer
class ActivityPub::DeleteNoteSerializer < ActivityPub::Serializer
class TombstoneSerializer < ActivityPub::Serializer
context_extensions :atom_uri

View File

@ -2,14 +2,15 @@
class ActivityPub::OutboxSerializer < ActivityPub::CollectionSerializer
def self.serializer_for(model, options)
if model.instance_of?(::ActivityPub::ActivityPresenter)
ActivityPub::ActivitySerializer
case model
when Status
model.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer
else
super
end
end
def items
object.items.map { |status| ActivityPub::ActivityPresenter.from_status(status) }
object.items
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ActivityPub::RemoveHashtagSerializer < ActivityPub::Serializer
attributes :type, :actor, :target
has_one :object, serializer: ActivityPub::HashtagSerializer
def type
'Remove'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def target
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class ActivityPub::RemoveNoteSerializer < ActivityPub::Serializer
attributes :type, :actor, :target
has_one :proper_object, key: :object
def type
'Remove'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def proper_object
ActivityPub::TagManager.instance.uri_for(object)
end
def target
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
end
end

View File

@ -1,43 +0,0 @@
# frozen_string_literal: true
class ActivityPub::RemoveSerializer < ActivityPub::Serializer
class UriSerializer < ActiveModel::Serializer
include RoutingHelper
def serializable_hash(*_args)
ActivityPub::TagManager.instance.uri_for(object)
end
end
def self.serializer_for(model, options)
case model.class.name
when 'Status'
UriSerializer
when 'FeaturedTag'
ActivityPub::HashtagSerializer
else
super
end
end
include RoutingHelper
attributes :type, :actor, :target
has_one :proper_object, key: :object
def type
'Remove'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def proper_object
object
end
def target
ActivityPub::TagManager.instance.collection_uri_for(object.account, :featured)
end
end

View File

@ -3,7 +3,11 @@
class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :to
has_one :virtual_object, key: :object, serializer: ActivityPub::ActivitySerializer
has_one :virtual_object, key: :object, serializer: ActivityPub::AnnounceNoteSerializer do |serializer|
serializer.send(:instance_options)[:allow_inlining] = false
object
end
def id
[ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
@ -22,6 +26,6 @@ class ActivityPub::UndoAnnounceSerializer < ActivityPub::Serializer
end
def virtual_object
ActivityPub::ActivityPresenter.from_status(object, allow_inlining: false)
object
end
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ActivityPub::UpdateSerializer < ActivityPub::Serializer
class ActivityPub::UpdateActorSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :to
has_one :object, serializer: ActivityPub::ActorSerializer

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
class ActivityPub::UpdateNoteSerializer < ActivityPub::Serializer
attributes :id, :type, :actor, :published, :to, :cc
has_one :object, serializer: ActivityPub::NoteSerializer
def id
[ActivityPub::TagManager.instance.uri_for(object), '#updates/', edited_at.to_i].join
end
def type
'Update'
end
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to
ActivityPub::TagManager.instance.to(object)
end
def cc
ActivityPub::TagManager.instance.cc(object)
end
def published
edited_at.iso8601
end
private
def edited_at
instance_options[:updated_at]&.to_datetime || object.edited_at
end
end

View File

@ -34,7 +34,8 @@ class BackupService < BaseService
add_comma = true
file.write(statuses.map do |status|
item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer)
serializer = status.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer
item = serialize_payload(status, serializer)
item.delete(:@context)
unless item[:type] == 'Announce' || item[:object][:attachment].blank?

View File

@ -32,6 +32,6 @@ class CreateCollectionService
end
def activity_json
ActiveModelSerializers::SerializableResource.new(@collection, serializer: ActivityPub::AddSerializer, adapter: ActivityPub::Adapter).to_json
ActiveModelSerializers::SerializableResource.new(@collection, serializer: ActivityPub::AddFeaturedCollectionSerializer, adapter: ActivityPub::Adapter).to_json
end
end

View File

@ -26,6 +26,6 @@ class CreateFeaturedTagService < BaseService
private
def build_json(featured_tag)
Oj.dump(serialize_payload(featured_tag, ActivityPub::AddSerializer, signer: @account))
Oj.dump(serialize_payload(featured_tag, ActivityPub::AddHashtagSerializer, signer: @account))
end
end

View File

@ -49,8 +49,4 @@ class ReblogService < BaseService
def increment_statistics
ActivityTracker.increment('activity:interactions')
end
def build_json(reblog)
Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(reblog), ActivityPub::ActivitySerializer, signer: reblog.account))
end
end

View File

@ -26,6 +26,6 @@ class RemoveFeaturedTagService < BaseService
private
def build_json(featured_tag)
Oj.dump(serialize_payload(featured_tag, ActivityPub::RemoveSerializer, signer: @account))
Oj.dump(serialize_payload(featured_tag, ActivityPub::RemoveHashtagSerializer, signer: @account))
end
end

View File

@ -103,7 +103,7 @@ class RemoveStatusService < BaseService
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, always_sign: true))
@signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteNoteSerializer, signer: @account, always_sign: true))
end
def remove_reblogs

View File

@ -72,6 +72,6 @@ class SuspendAccountService < BaseService
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account))
end
end

View File

@ -63,6 +63,6 @@ class UnsuspendAccountService < BaseService
end
def signed_activity_json
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
@signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account))
end
end

View File

@ -24,11 +24,15 @@ class ActivityPub::DistributionWorker < ActivityPub::RawDistributionWorker
end
def payload
@payload ||= Oj.dump(serialize_payload(activity, ActivityPub::ActivitySerializer, signer: @account))
@payload ||= Oj.dump(serialize_payload(@status, activity_serializer, serializer_options.merge(signer: @account)))
end
def activity
ActivityPub::ActivityPresenter.from_status(@status)
def activity_serializer
@status.reblog? ? ActivityPub::AnnounceNoteSerializer : ActivityPub::CreateNoteSerializer
end
def serializer_options
{}
end
def options

View File

@ -15,15 +15,11 @@ class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWor
protected
def activity
ActivityPub::ActivityPresenter.new(
id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @options[:updated_at]&.to_datetime&.to_i || @status.edited_at.to_i].join,
type: 'Update',
actor: ActivityPub::TagManager.instance.uri_for(@status.account),
published: @options[:updated_at]&.to_datetime || @status.edited_at,
to: ActivityPub::TagManager.instance.to(@status),
cc: ActivityPub::TagManager.instance.cc(@status),
virtual_object: @status
)
def activity_serializer
ActivityPub::UpdateNoteSerializer
end
def serializer_options
super.merge({ updated_at: @options[:updated_at] })
end
end

View File

@ -23,6 +23,6 @@ class ActivityPub::UpdateDistributionWorker < ActivityPub::RawDistributionWorker
end
def payload
@payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account, sign_with: @options[:sign_with]))
@payload ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateActorSerializer, signer: @account, sign_with: @options[:sign_with]))
end
end

View File

@ -1,102 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::ActivitySerializer do
subject { serialized_record_json(presenter, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:status) { Fabricate(:status, created_at: Time.utc(2026, 0o1, 27, 15, 29, 31)) }
context 'with a new status' do
let(:presenter) { ActivityPub::ActivityPresenter.from_status(status) }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(status),
'type' => 'Create',
'actor' => tag_manager.uri_for(status.account),
'published' => '2026-01-27T15:29:31Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [a_string_matching(/followers$/)],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end
context 'with a new reblog' do
let(:reblog) { Fabricate(:status, reblog: status, created_at: Time.utc(2026, 0o1, 27, 16, 21, 44)) }
let(:presenter) { ActivityPub::ActivityPresenter.from_status(reblog) }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(reblog),
'type' => 'Announce',
'actor' => tag_manager.uri_for(reblog.account),
'published' => '2026-01-27T16:21:44Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [tag_manager.uri_for(status.account), a_string_matching(/followers$/)],
'object' => tag_manager.uri_for(status),
})
expect(subject).to_not have_key('target')
end
context 'when inlining of private local status is allowed' do
let(:status) { Fabricate(:status, visibility: :private, created_at: Time.utc(2026, 0o1, 27, 15, 29, 31)) }
let(:reblog) { Fabricate(:status, reblog: status, account: status.account, created_at: Time.utc(2026, 0o1, 27, 16, 21, 44)) }
let(:presenter) { ActivityPub::ActivityPresenter.from_status(reblog, allow_inlining: true) }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(reblog),
'type' => 'Announce',
'actor' => tag_manager.uri_for(reblog.account),
'published' => '2026-01-27T16:21:44Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [tag_manager.uri_for(status.account), a_string_matching(/followers$/)],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end
end
context 'with a custom presenter for a status `Update`' do
let(:status) { Fabricate(:status, edited_at: Time.utc(2026, 0o1, 27, 15, 29, 31)) }
let(:presenter) do
ActivityPub::ActivityPresenter.new(
id: 'https://localhost/status/1#updates/1769527771',
type: 'Update',
actor: 'https://localhost/actor/1',
published: status.edited_at,
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: ['https://localhost/actor/1/followers'],
virtual_object: status
)
end
it 'serializes to the expected json' do
expect(subject).to include({
'id' => 'https://localhost/status/1#updates/1769527771',
'type' => 'Update',
'actor' => 'https://localhost/actor/1',
'published' => '2026-01-27T15:29:31Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => ['https://localhost/actor/1/followers'],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::AddFeaturedCollectionSerializer do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:object) { Fabricate(:collection) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured_collections$}),
'object' => a_hash_including({
'type' => 'FeaturedCollection',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::AddHashtagSerializer do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:object) { Fabricate(:featured_tag) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => a_hash_including({
'type' => 'Hashtag',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::AddNoteSerializer do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:object) { Fabricate(:status) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => tag_manager.uri_for(object),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end

View File

@ -1,119 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::AddSerializer do
describe '.serializer_for' do
subject { described_class.serializer_for(model, {}) }
context 'with a Status model' do
let(:model) { Status.new }
it { is_expected.to eq(described_class::UriSerializer) }
end
context 'with a FeaturedTag model' do
let(:model) { FeaturedTag.new }
it { is_expected.to eq(ActivityPub::HashtagSerializer) }
end
context 'with a Collection model' do
let(:model) { Collection.new }
it { is_expected.to eq(ActivityPub::FeaturedCollectionSerializer) }
end
context 'with an Array' do
let(:model) { [] }
it { is_expected.to eq(ActiveModel::Serializer::CollectionSerializer) }
end
end
describe '#target' do
subject { described_class.new(object).target }
context 'when object is a Status' do
let(:object) { Fabricate(:status) }
it { is_expected.to match(%r{/#{object.account_id}/collections/featured$}) }
end
context 'when object is a FeaturedTag' do
let(:object) { Fabricate(:featured_tag) }
it { is_expected.to match(%r{/#{object.account_id}/collections/featured$}) }
end
context 'when object is a Collection' do
let(:object) { Fabricate(:collection) }
it { is_expected.to match(%r{/#{object.account_id}/featured_collections$}) }
end
end
describe 'Serialization' do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
context 'with a status' do
let(:object) { Fabricate(:status) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => tag_manager.uri_for(object),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end
context 'with a featured tag' do
let(:object) { Fabricate(:featured_tag) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => a_hash_including({
'type' => 'Hashtag',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end
context 'with a collection' do
let(:object) { Fabricate(:collection) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Add',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured_collections$}),
'object' => a_hash_including({
'type' => 'FeaturedCollection',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end
end
end

View File

@ -0,0 +1,79 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::AnnounceNoteSerializer do
subject { serialized_record_json(reblog, described_class, adapter: ActivityPub::Adapter, options:) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:status) { Fabricate(:status, created_at: Time.utc(2026, 1, 27, 15, 29, 31)) }
let(:reblog) { Fabricate(:status, reblog: status, created_at: Time.utc(2026, 1, 27, 16, 21, 44)) }
let(:options) { {} }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(reblog),
'type' => 'Announce',
'actor' => tag_manager.uri_for(reblog.account),
'published' => '2026-01-27T16:21:44Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [tag_manager.uri_for(status.account), a_string_matching(/followers$/)],
'object' => tag_manager.uri_for(status),
})
expect(subject).to_not have_key('target')
end
context 'when status is local and private' do
let(:status) { Fabricate(:status, visibility: :private, created_at: Time.utc(2026, 1, 27, 15, 29, 31)) }
let(:reblog) { Fabricate(:status, reblog: status, account: status.account, created_at: Time.utc(2026, 1, 27, 16, 21, 44)) }
context 'when inlining of private local status is allowed' do
shared_examples 'serialization with inlining' do
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(reblog),
'type' => 'Announce',
'actor' => tag_manager.uri_for(reblog.account),
'published' => '2026-01-27T16:21:44Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [tag_manager.uri_for(status.account), a_string_matching(/followers$/)],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end
context 'with `allow_inlining` explicitly set to `true`' do
let(:options) { { allow_inlining: true } }
it_behaves_like 'serialization with inlining'
end
context 'with `allow_inlining` unset' do
let(:options) { {} }
it_behaves_like 'serialization with inlining'
end
end
context 'when inlining is not allowed' do
let(:options) { { allow_inlining: false } }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(reblog),
'type' => 'Announce',
'actor' => tag_manager.uri_for(reblog.account),
'published' => '2026-01-27T16:21:44Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [tag_manager.uri_for(status.account), a_string_matching(/followers$/)],
'object' => tag_manager.uri_for(status),
})
end
end
end
end

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::CreateNoteSerializer do
subject { serialized_record_json(status, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:status) { Fabricate(:status, created_at: Time.utc(2026, 1, 27, 15, 29, 31)) }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => tag_manager.activity_uri_for(status),
'type' => 'Create',
'actor' => tag_manager.uri_for(status.account),
'published' => '2026-01-27T15:29:31Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [a_string_matching(/followers$/)],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end

View File

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe ActivityPub::DeleteSerializer do
RSpec.describe ActivityPub::DeleteNoteSerializer do
subject { serialized_record_json(status, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::RemoveHashtagSerializer do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:object) { Fabricate(:featured_tag) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Remove',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => a_hash_including({
'type' => 'Hashtag',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::RemoveNoteSerializer do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:object) { Fabricate(:status) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Remove',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => tag_manager.uri_for(object),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end

View File

@ -1,71 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::RemoveSerializer do
describe '.serializer_for' do
subject { described_class.serializer_for(model, {}) }
context 'with a Status model' do
let(:model) { Status.new }
it { is_expected.to eq(described_class::UriSerializer) }
end
context 'with a FeaturedTag model' do
let(:model) { FeaturedTag.new }
it { is_expected.to eq(ActivityPub::HashtagSerializer) }
end
context 'with an Array' do
let(:model) { [] }
it { is_expected.to eq(ActiveModel::Serializer::CollectionSerializer) }
end
end
describe 'Serialization' do
subject { serialized_record_json(object, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
context 'with a status' do
let(:object) { Fabricate(:status) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Remove',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => tag_manager.uri_for(object),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end
context 'with a featured tag' do
let(:object) { Fabricate(:featured_tag) }
it 'serializes to the expected json' do
expect(subject).to include({
'type' => 'Remove',
'actor' => tag_manager.uri_for(object.account),
'target' => a_string_matching(%r{/featured$}),
'object' => a_hash_including({
'type' => 'Hashtag',
}),
})
expect(subject).to_not have_key('id')
expect(subject).to_not have_key('published')
expect(subject).to_not have_key('to')
expect(subject).to_not have_key('cc')
end
end
end
end

View File

@ -26,4 +26,17 @@ RSpec.describe ActivityPub::UndoAnnounceSerializer do
expect(subject).to_not have_key('cc')
expect(subject).to_not have_key('target')
end
context 'when status is local and private' do
let(:status) { Fabricate(:status, visibility: :private) }
let(:reblog) { Fabricate(:status, reblog: status, account: status.account) }
it 'does not inline the status' do
expect(subject).to include({
'object' => a_hash_including({
'object' => tag_manager.uri_for(status),
}),
})
end
end
end

View File

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe ActivityPub::UpdateSerializer do
RSpec.describe ActivityPub::UpdateActorSerializer do
subject { serialized_record_json(account, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::UpdateNoteSerializer do
subject { serialized_record_json(status, described_class, adapter: ActivityPub::Adapter) }
let(:tag_manager) { ActivityPub::TagManager.instance }
let(:status) { Fabricate(:status, edited_at: Time.utc(2026, 1, 27, 15, 29, 31)) }
it 'serializes to the expected json' do
expect(subject).to include({
'id' => "#{tag_manager.uri_for(status)}#updates/1769527771",
'type' => 'Update',
'actor' => tag_manager.uri_for(status.account),
'published' => '2026-01-27T15:29:31Z',
'to' => ['https://www.w3.org/ns/activitystreams#Public'],
'cc' => [a_string_matching(%r{/followers$})],
'object' => a_hash_including(
'id' => tag_manager.uri_for(status)
),
})
expect(subject).to_not have_key('target')
end
end

View File

@ -51,5 +51,46 @@ RSpec.describe ActivityPub::DistributionWorker do
end
end
end
context 'with a reblog' do
before do
follower.follow!(reblog.account)
end
context 'when the reblogged status is not private' do
let(:status) { Fabricate(:status) }
let(:reblog) { Fabricate(:status, reblog: status) }
it 'delivers an activity without inlining the status' do
expected_json = {
type: 'Announce',
object: ActivityPub::TagManager.instance.uri_for(status),
}
expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[match_json_values(expected_json), reblog.account.id, 'http://example.com', anything]]) do
subject.perform(reblog.id)
end
end
end
context 'when the reblogged status is private' do
let(:status) { Fabricate(:status, visibility: :private) }
let(:reblog) { Fabricate(:status, reblog: status, account: status.account) }
it 'delivers an activity that inlines the status' do
expected_json = {
type: 'Announce',
object: a_hash_including({
id: ActivityPub::TagManager.instance.uri_for(status),
type: 'Note',
}),
}
expect_push_bulk_to_match(ActivityPub::DeliveryWorker, [[match_json_values(expected_json), reblog.account.id, 'http://example.com', anything]]) do
subject.perform(reblog.id)
end
end
end
end
end
end