From 39d9da3b82a9085c6a8df250d48639f1f6203d41 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 23 Mar 2026 14:11:33 +0100 Subject: [PATCH] Tag linked FeaturedCollection objects over ActivityPub (#38115) --- app/lib/activitypub/activity/create.rb | 20 +++++++ app/lib/activitypub/tag_manager.rb | 10 ++++ app/models/status.rb | 2 + app/models/tagged_object.rb | 19 +++++++ .../activitypub/note_serializer.rb | 7 +-- app/serializers/rest/collection_serializer.rb | 2 +- .../rest/shallow_tag_serializer.rb | 11 ++++ app/serializers/rest/status_serializer.rb | 15 +++--- .../process_status_update_service.rb | 26 +++++++++- app/services/post_status_service.rb | 5 ++ app/services/process_links_service.rb | 52 +++++++++++++++++++ app/services/update_status_service.rb | 1 + .../20260319142348_create_tagged_objects.rb | 17 ++++++ db/schema.rb | 16 +++++- spec/fabricators/tagged_object_fabricator.rb | 8 +++ spec/lib/activitypub/activity/create_spec.rb | 24 +++++++++ spec/lib/activitypub/tag_manager_spec.rb | 10 ++++ .../activitypub/note_serializer_spec.rb | 20 +++++++ .../rest/status_serializer_spec.rb | 17 ++++++ .../process_status_update_service_spec.rb | 12 +++++ spec/services/post_status_service_spec.rb | 10 ++++ spec/services/process_links_service_spec.rb | 19 +++++++ spec/services/update_status_service_spec.rb | 13 +++++ 23 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 app/models/tagged_object.rb create mode 100644 app/serializers/rest/shallow_tag_serializer.rb create mode 100644 app/services/process_links_service.rb create mode 100644 db/migrate/20260319142348_create_tagged_objects.rb create mode 100644 spec/fabricators/tagged_object_fabricator.rb create mode 100644 spec/services/process_links_service_spec.rb diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index cd4e1a3421..1b71c03b9e 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -42,6 +42,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_status @tags = [] @mentions = [] + @tagged_objects = [] @unresolved_mentions = [] @silenced_account_ids = [] @params = {} @@ -56,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity ApplicationRecord.transaction do @status = Status.create!(@params.merge(quote: @quote)) attach_tags(@status) + attach_tagged_objects(@status) attach_mentions(@status) attach_counts(@status) end @@ -181,6 +183,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def attach_tagged_objects(status) + @tagged_objects.each do |tagged_object| + tagged_object.status = status + tagged_object.save + end + end + def attach_mentions(status) @mentions.each do |mention| mention.status = status @@ -210,6 +219,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity process_mention tag elsif equals_or_includes?(tag['type'], 'Emoji') process_emoji tag + elsif equals_or_includes?(tag['type'], 'FeaturedCollection') + process_tagged_collection tag end end end @@ -266,6 +277,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity end end + def process_tagged_collection(tag) + return if tag['id'].blank? + + # TODO: We probably want to resolve unknown objects and push them to an `@unresolved_tagged_objects` on failure + collection = ActivityPub::TagManager.instance.uri_to_resource(tag['id'], Collection) + + @tagged_objects << TaggedObject.new(uri: ActivityPub::TagManager.instance.uri_for(collection), object: collection, ap_type: 'FeaturedCollection') if collection.present? + end + def process_attachments return [] if @object['attachment'].nil? diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index be9003082d..9036811217 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -262,6 +262,14 @@ class ActivityPub::TagManager uri_to_resource(uri, Account) end + def uri_to_local_collection(uri) + path_params = Rails.application.routes.recognize_path(uri) + return unless path_params[:controller] == 'collections' + + # TODO: check account, but this requires handling potentially two different schemes + Collection.find_by(id: path_params[:id]) + end + def uri_to_local_conversation(uri) path_params = Rails.application.routes.recognize_path(uri) return unless path_params[:controller] == 'activitypub/contexts' @@ -279,6 +287,8 @@ class ActivityPub::TagManager uris_to_local_accounts([uri]).first when 'Conversation' uri_to_local_conversation(uri) + when 'Collection' + uri_to_local_collection(uri) else StatusFinder.new(uri).status end diff --git a/app/models/status.rb b/app/models/status.rb index c97465939b..79e9071402 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -80,6 +80,7 @@ class Status < ApplicationRecord has_many :mentions, dependent: :destroy, inverse_of: :status has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account' has_many :media_attachments, dependent: :nullify + has_many :tagged_objects, dependent: :destroy has_many :quotes, foreign_key: 'quoted_status_id', inverse_of: :quoted_status, dependent: :nullify # The `dependent` option is enabled by the initial `mentions` association declaration @@ -172,6 +173,7 @@ class Status < ApplicationRecord preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } }, account: [:account_stat, user: :role], active_mentions: :account, + tagged_objects: :object, reblog: [ :application, :media_attachments, diff --git a/app/models/tagged_object.rb b/app/models/tagged_object.rb new file mode 100644 index 0000000000..7f4b83f431 --- /dev/null +++ b/app/models/tagged_object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tagged_objects +# +# id :bigint(8) not null, primary key +# ap_type :string not null +# object_type :string +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# object_id :bigint(8) +# status_id :bigint(8) not null +# +class TaggedObject < ApplicationRecord + belongs_to :status, inverse_of: :tagged_objects + belongs_to :object, polymorphic: true, optional: true +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 455f8d72db..a8e1315bee 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -139,7 +139,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end def virtual_tags - object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis + object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis + object.tagged_objects.map(&:object) end def atom_uri @@ -345,8 +345,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer end end - class CustomEmojiSerializer < ActivityPub::EmojiSerializer - end + class CustomEmojiSerializer < ActivityPub::EmojiSerializer; end + + class CollectionSerializer < ActivityPub::FeaturedCollectionSerializer; end class OptionSerializer < ActivityPub::Serializer class RepliesSerializer < ActivityPub::Serializer diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb index ac7c8ad026..c3f2b55a84 100644 --- a/app/serializers/rest/collection_serializer.rb +++ b/app/serializers/rest/collection_serializer.rb @@ -5,7 +5,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer :local, :sensitive, :discoverable, :item_count, :created_at, :updated_at - belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer + belongs_to :tag, serializer: REST::ShallowTagSerializer has_many :items, serializer: REST::CollectionItemSerializer diff --git a/app/serializers/rest/shallow_tag_serializer.rb b/app/serializers/rest/shallow_tag_serializer.rb new file mode 100644 index 0000000000..cdf2a3e1f9 --- /dev/null +++ b/app/serializers/rest/shallow_tag_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class REST::ShallowTagSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :name, :url + + def url + tag_url(object) + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 96751fbd57..8468029f7d 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_many :ordered_mentions, key: :mentions has_many :tags has_many :emojis, serializer: REST::CustomEmojiSerializer + has_many :tagged_collections, serializer: REST::CollectionSerializer # Due to a ActiveModel::Serializer quirk, if you change any of the following, have a look at # updating `app/serializers/rest/shallow_status_serializer.rb` as well @@ -166,6 +167,10 @@ class REST::StatusSerializer < ActiveModel::Serializer object.active_mentions.to_a.sort_by(&:id) end + def tagged_collections + object.tagged_objects.filter_map { |tagged_object| tagged_object.object if tagged_object.ap_type == 'FeaturedCollection' } + end + def quote_approval { automatic: object.proper.quote_policy_as_keys(:automatic), @@ -208,13 +213,5 @@ class REST::StatusSerializer < ActiveModel::Serializer end end - class TagSerializer < ActiveModel::Serializer - include RoutingHelper - - attributes :name, :url - - def url - tag_url(object) - end - end + class TagSerializer < REST::ShallowTagSerializer; end end diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index ce47003441..2875686177 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -182,9 +182,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService end def update_metadata! - @raw_tags = [] + @raw_tags = [] @raw_mentions = [] - @raw_emojis = [] + @raw_tagged_objects = [] + @raw_emojis = [] as_array(@json['tag']).each do |tag| if equals_or_includes?(tag['type'], 'Hashtag') @@ -193,10 +194,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService @raw_mentions << tag['href'] if tag['href'].present? elsif equals_or_includes?(tag['type'], 'Emoji') @raw_emojis << tag + elsif equals_or_includes?(tag['type'], 'FeaturedCollection') + @raw_tagged_objects << tag if tag['id'] end end update_tags! + update_tagged_objects! update_mentions! update_emojis! update_quote! @@ -229,6 +233,24 @@ class ActivityPub::ProcessStatusUpdateService < BaseService end end + def update_tagged_objects! + current_tagged_objects = @raw_tagged_objects.filter_map do |tagged_object| + url = tagged_object['id'] + + # TODO: We probably want to resolve unknown objects at authoring time + ActivityPub::TagManager.instance.uri_to_resource(url, Collection) + end + + # Any previously-unresolved URI would be resolved here + @status.tagged_objects.upsert_all( + current_tagged_objects.uniq.map { |object| { object_type: object.class.name, object_id: object.id, uri: ActivityPub::TagManager.instance.uri_for(object), ap_type: 'FeaturedCollection' } }, + unique_by: %w(status_id uri) + ) + + # Remove unused links + @status.tagged_objects.where.not(uri: current_tagged_objects.map { |object| ActivityPub::TagManager.instance.uri_for(object) }).delete_all + end + def update_mentions! unresolved_mentions = [] diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index ce1076d7c3..e44ddda54c 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -80,6 +80,7 @@ class PostStatusService < BaseService process_mentions_service.call(@status) safeguard_mentions!(@status) safeguard_private_mention_quote!(@status) + attach_tagged_objects!(@status) attach_quote!(@status) antispam = Antispam.new(@status) @@ -111,6 +112,10 @@ class PostStatusService < BaseService status.quote.accept! if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote? end + def attach_tagged_objects!(status) + ProcessLinksService.new.call(status) + end + def safeguard_mentions!(status) return if @options[:allowed_mentions].nil? diff --git a/app/services/process_links_service.rb b/app/services/process_links_service.rb new file mode 100644 index 0000000000..7b84f1180d --- /dev/null +++ b/app/services/process_links_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ProcessLinksService < BaseService + include Payloadable + + # Scan status for links to ActivityPub objects and attach them to statuses + # @param [Status] status + def call(status) + return unless status.local? + + @status = status + @previous_objects = @status.tagged_objects.includes(:object).to_a + @current_objects = [] + + Status.transaction do + scan_text! + assign_tagged_objects! + end + end + + private + + def scan_text! + urls = @status.text.scan(FetchLinkCardService::URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize } + + urls.each do |url| + # We only support `FeaturedCollection` at this time + + # TODO: We probably want to resolve unknown objects at authoring time + object = ActivityPub::TagManager.instance.uri_to_resource(url.to_s, Collection) + + tagged_object = @previous_objects.find { |x| x.object == object || x.uri == url } + tagged_object ||= @current_objects.find { |x| x.object == object || x.uri == url } + tagged_object ||= @status.tagged_objects.new(object: object, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(object)) + + @current_objects << tagged_object + end + end + + def assign_tagged_objects! + return unless @status.persisted? + + @current_objects.each do |object| + object.save if object.new_record? + end + + # If previous objects are no longer contained in the text, remove them to lighten the database + removed_objects = @previous_objects - @current_objects + + TaggedObject.where(id: removed_objects.map(&:id)).delete_all unless removed_objects.empty? + end +end diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb index 4b871211a4..ac79dd6807 100644 --- a/app/services/update_status_service.rb +++ b/app/services/update_status_service.rb @@ -134,6 +134,7 @@ class UpdateStatusService < BaseService def update_metadata! ProcessHashtagsService.new.call(@status) ProcessMentionsService.new.call(@status) + ProcessLinksService.new.call(@status) end def broadcast_updates! diff --git a/db/migrate/20260319142348_create_tagged_objects.rb b/db/migrate/20260319142348_create_tagged_objects.rb new file mode 100644 index 0000000000..ae0ebc49f8 --- /dev/null +++ b/db/migrate/20260319142348_create_tagged_objects.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateTaggedObjects < ActiveRecord::Migration[8.1] + def change + create_table :tagged_objects do |t| + t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: false + t.references :object, polymorphic: true, null: true + t.string :ap_type, null: false + t.string :uri + + t.timestamps + end + + add_index :tagged_objects, [:status_id, :object_type, :object_id], unique: true, where: 'object_type IS NOT NULL AND object_id IS NOT NULL' + add_index :tagged_objects, [:status_id, :uri], unique: true, where: 'uri IS NOT NULL' + end +end diff --git a/db/schema.rb b/db/schema.rb index fedc9497c8..4b9dbbcd36 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do +ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -1242,6 +1242,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do t.index ["tag_id", "language"], name: "index_tag_trends_on_tag_id_and_language", unique: true end + create_table "tagged_objects", force: :cascade do |t| + t.string "ap_type", null: false + t.datetime "created_at", null: false + t.bigint "object_id" + t.string "object_type" + t.bigint "status_id", null: false + t.datetime "updated_at", null: false + t.string "uri" + t.index ["object_type", "object_id"], name: "index_tagged_objects_on_object" + t.index ["status_id", "object_type", "object_id"], name: "idx_on_status_id_object_type_object_id_d6ebe374bd", unique: true, where: "((object_type IS NOT NULL) AND (object_id IS NOT NULL))" + t.index ["status_id", "uri"], name: "index_tagged_objects_on_status_id_and_uri", unique: true, where: "(uri IS NOT NULL)" + end + create_table "tags", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.string "display_name" @@ -1547,6 +1560,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do add_foreign_key "tag_follows", "accounts", on_delete: :cascade add_foreign_key "tag_follows", "tags", on_delete: :cascade add_foreign_key "tag_trends", "tags", on_delete: :cascade + add_foreign_key "tagged_objects", "statuses", on_delete: :cascade add_foreign_key "tombstones", "accounts", on_delete: :cascade add_foreign_key "user_invite_requests", "users", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade diff --git a/spec/fabricators/tagged_object_fabricator.rb b/spec/fabricators/tagged_object_fabricator.rb new file mode 100644 index 0000000000..5c0b6b94b9 --- /dev/null +++ b/spec/fabricators/tagged_object_fabricator.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Fabricator(:tagged_object) do + status + object nil + ap_type 'FeaturedCollection' + uri { Faker::Internet.device_token } +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 6c2f165c0e..f21552caf5 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -681,6 +681,30 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with tagged Featured Collections' do + let(:featured_collection) { Fabricate(:collection) } + + let(:object_json) do + build_object( + tag: [ + { + type: 'FeaturedCollection', + id: ActivityPub::TagManager.instance.uri_for(featured_collection), + }, + ] + ) + end + + it 'creates the status with appropriate tagged objects' do + expect { subject.perform } + .to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + + expect(status.tagged_objects.map(&:object)).to contain_exactly(featured_collection) + end + end + context 'with hashtags' do let(:object_json) do build_object( diff --git a/spec/lib/activitypub/tag_manager_spec.rb b/spec/lib/activitypub/tag_manager_spec.rb index a15529057c..0da55dd70b 100644 --- a/spec/lib/activitypub/tag_manager_spec.rb +++ b/spec/lib/activitypub/tag_manager_spec.rb @@ -671,5 +671,15 @@ RSpec.describe ActivityPub::TagManager do status = Fabricate(:status, uri: 'https://example.com/123') expect(subject.uri_to_resource('https://example.com/123#456', Status)).to eq status end + + it 'returns the local featured collection' do + collection = Fabricate(:collection) + expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection + end + + it 'returns the remote featured collection' do + collection = Fabricate(:remote_collection) + expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection + end end end diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 4970de709d..dc71a19c6e 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -43,6 +43,26 @@ RSpec.describe ActivityPub::NoteSerializer do .and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility end + context 'with tagged featured collections' do + let(:collection) { Fabricate(:collection) } + + before do + parent.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection)) + end + + it 'has the expected shape' do + expect(subject).to include({ + 'type' => 'Note', + 'tag' => include( + a_hash_including({ + 'type' => 'FeaturedCollection', + 'id' => ActivityPub::TagManager.instance.uri_for(collection), + }) + ), + }) + end + end + context 'with a quote' do let(:quoted_status) { Fabricate(:status) } let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) } diff --git a/spec/serializers/rest/status_serializer_spec.rb b/spec/serializers/rest/status_serializer_spec.rb index 510328c7fb..0ad4841791 100644 --- a/spec/serializers/rest/status_serializer_spec.rb +++ b/spec/serializers/rest/status_serializer_spec.rb @@ -92,5 +92,22 @@ RSpec.describe REST::StatusSerializer do ) end end + + context 'with a tagged collection' do + let(:collection) { Fabricate(:collection) } + + before do + status.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection)) + end + + it 'contains the tagged collection' do + expect(subject) + .to include( + 'tagged_collections' => [a_hash_including( + 'id' => collection.id.to_s + )] + ) + end + end end end diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index bcff6f1c41..dd24c3d8be 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -20,6 +20,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) }, { type: 'Mention', href: bogus_mention }, + { type: 'FeaturedCollection', id: ActivityPub::TagManager.instance.uri_for(featured_collection) }, ], } end @@ -27,6 +28,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do let(:alice) { Fabricate(:account) } let(:bob) { Fabricate(:account) } + let(:featured_collection) { Fabricate(:collection) } let(:mentions) { [] } let(:tags) { [] } @@ -276,6 +278,16 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do end end + context 'when originally without tagged objects' do + before do + subject.call(status, json, json) + end + + it 'updates tags' do + expect(status.tagged_objects.reload.map(&:object)).to contain_exactly(featured_collection) + end + end + context 'when originally without tags' do before do subject.call(status, json, json) diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index e1ed3c2b18..66ef93f807 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -209,6 +209,16 @@ RSpec.describe PostStatusService do expect(hashtags_service).to have_received(:call).with(status) end + it 'processes tagged objects' do + account = Fabricate(:account) + collection = Fabricate(:collection) + + status = subject.call(account, text: "test #{ActivityPub::TagManager.instance.uri_for(collection)} #{ActivityPub::TagManager.instance.uri_for(collection)}") + + expect(status.tagged_objects.map(&:object)) + .to contain_exactly(collection) + end + it 'gets distributed' do allow(DistributionWorker).to receive(:perform_async) allow(ActivityPub::DistributionWorker).to receive(:perform_async) diff --git a/spec/services/process_links_service_spec.rb b/spec/services/process_links_service_spec.rb new file mode 100644 index 0000000000..b965470f15 --- /dev/null +++ b/spec/services/process_links_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ProcessLinksService do + subject { described_class.new } + + let(:account) { Fabricate(:account, username: 'alice') } + + context 'when status mentions known collections' do + let!(:collection) { Fabricate(:collection) } + let(:status) { Fabricate(:status, account: account, text: "Hello check out this collection! #{ActivityPub::TagManager.instance.uri_for(collection)}", visibility: :public) } + + it 'creates a tagged object' do + expect { subject.call(status) } + .to change { status.tagged_objects.count }.by(1) + end + end +end diff --git a/spec/services/update_status_service_spec.rb b/spec/services/update_status_service_spec.rb index 463605dd22..bbbf8bc420 100644 --- a/spec/services/update_status_service_spec.rb +++ b/spec/services/update_status_service_spec.rb @@ -161,6 +161,19 @@ RSpec.describe UpdateStatusService do end end + context 'when tagged objects in text change' do + let!(:old_collection) { Fabricate(:collection) } + let!(:new_collection) { Fabricate(:collection) } + + let!(:account) { Fabricate(:account) } + let!(:status) { PostStatusService.new.call(account, text: "Check out #{ActivityPub::TagManager.instance.uri_for(old_collection)}") } + + it 'changes tagged objects' do + expect { subject.call(status, status.account_id, text: "Check out #{ActivityPub::TagManager.instance.uri_for(new_collection)} #{ActivityPub::TagManager.instance.uri_for(new_collection)}") } + .to change { status.reload.tagged_objects.map(&:object) }.from([old_collection]).to([new_collection]) + end + end + context 'when hashtags in text change' do let!(:account) { Fabricate(:account) } let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') }