user follows/followed by scans for bluesky
This commit is contained in:
@@ -93,11 +93,18 @@ module GoodJobHelper
|
||||
|
||||
sig { params(job: GoodJob::Job).returns(T::Array[JobArg]) }
|
||||
def arguments_for_job(job)
|
||||
deserialized =
|
||||
T.cast(
|
||||
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
|
||||
T::Hash[String, T.untyped],
|
||||
begin
|
||||
deserialized =
|
||||
T.cast(
|
||||
ActiveJob::Arguments.deserialize(job.serialized_params).to_h,
|
||||
T::Hash[String, T.untyped],
|
||||
)
|
||||
rescue ActiveJob::DeserializationError => e
|
||||
Rails.logger.error(
|
||||
"error deserializing job arguments: #{e.class.name} - #{e.message}",
|
||||
)
|
||||
return [JobArg.new(key: :error, value: e.message, inferred: true)]
|
||||
end
|
||||
args_hash =
|
||||
T.cast(deserialized["arguments"].first, T::Hash[Symbol, T.untyped])
|
||||
args =
|
||||
|
||||
@@ -106,11 +106,12 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
|
||||
num_filtered_posts = 0
|
||||
num_created_posts = 0
|
||||
num_pages = 0
|
||||
posts_scan = Domain::UserJobEvent::PostsScan.create!(user:)
|
||||
|
||||
loop do
|
||||
url = cursor ? "#{posts_url}&cursor=#{cursor}" : posts_url
|
||||
|
||||
response = http_client.get(url)
|
||||
posts_scan.update!(log_entry: response.log_entry) if num_pages == 0
|
||||
|
||||
num_pages += 1
|
||||
if response.status_code != 200
|
||||
@@ -176,6 +177,10 @@ class Domain::Bluesky::Job::ScanPostsJob < Domain::Bluesky::Job::Base
|
||||
end
|
||||
|
||||
user.scanned_posts_at = Time.current
|
||||
posts_scan.update!(
|
||||
total_posts_seen: num_processed_posts,
|
||||
new_posts_seen: num_created_posts,
|
||||
)
|
||||
logger.info(
|
||||
format_tags(
|
||||
"scanned posts",
|
||||
|
||||
264
app/jobs/domain/bluesky/job/scan_user_follows_job.rb
Normal file
264
app/jobs/domain/bluesky/job/scan_user_follows_job.rb
Normal file
@@ -0,0 +1,264 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Domain::Bluesky::Job::ScanUserFollowsJob < Domain::Bluesky::Job::Base
|
||||
self.default_priority = -10
|
||||
|
||||
sig { override.params(args: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
|
||||
def perform(args)
|
||||
user = user_from_args!
|
||||
|
||||
last_follows_scan = user.follows_scans.last
|
||||
if (ca = last_follows_scan&.created_at) && (ca > 1.month.ago) &&
|
||||
!force_scan?
|
||||
logger.info(
|
||||
format_tags(
|
||||
"skipping user #{user.did} follows scan",
|
||||
make_tags(
|
||||
ago: time_ago_in_words(ca),
|
||||
last_scan_id: last_follows_scan.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
perform_scan_type(
|
||||
user,
|
||||
"follows",
|
||||
bsky_method: "app.bsky.graph.getFollows",
|
||||
bsky_field: "follows",
|
||||
edge_name: :user_user_follows_from,
|
||||
user_attr: :from_id,
|
||||
other_attr: :to_id,
|
||||
)
|
||||
end
|
||||
|
||||
last_followed_by_scan = user.followed_by_scans.last
|
||||
if (ca = last_followed_by_scan&.created_at) && (ca > 1.month.ago) &&
|
||||
!force_scan?
|
||||
logger.info(
|
||||
format_tags(
|
||||
"skipping user #{user.did} followed by scan",
|
||||
make_tags(
|
||||
ago: time_ago_in_words(ca),
|
||||
last_scan_id: last_followed_by_scan.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
perform_scan_type(
|
||||
user,
|
||||
"followed_by",
|
||||
bsky_method: "app.bsky.graph.getFollowers",
|
||||
bsky_field: "followers",
|
||||
edge_name: :user_user_follows_to,
|
||||
user_attr: :to_id,
|
||||
other_attr: :from_id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
sig do
|
||||
params(
|
||||
user: Domain::User::BlueskyUser,
|
||||
kind: String,
|
||||
bsky_method: String,
|
||||
bsky_field: String,
|
||||
edge_name: Symbol,
|
||||
user_attr: Symbol,
|
||||
other_attr: Symbol,
|
||||
).void
|
||||
end
|
||||
def perform_scan_type(
|
||||
user,
|
||||
kind,
|
||||
bsky_method:,
|
||||
bsky_field:,
|
||||
edge_name:,
|
||||
user_attr:,
|
||||
other_attr:
|
||||
)
|
||||
scan = Domain::UserJobEvent::FollowScan.create!(user:, kind:)
|
||||
cursor = T.let(nil, T.nilable(String))
|
||||
page = 0
|
||||
subjects_data = T.let([], T::Array[Bluesky::Graph::Subject])
|
||||
|
||||
loop do
|
||||
# get followers
|
||||
xrpc_url =
|
||||
"https://public.api.bsky.app/xrpc/#{bsky_method}?actor=#{user.did!}&limit=100"
|
||||
xrpc_url = "#{xrpc_url}&cursor=#{cursor}" if cursor
|
||||
|
||||
response = http_client.get(xrpc_url)
|
||||
scan.update!(log_entry: response.log_entry) if page == 0
|
||||
page += 1
|
||||
|
||||
if response.status_code != 200
|
||||
fatal_error(
|
||||
format_tags(
|
||||
"failed to get user #{kind}",
|
||||
make_tags(status_code: response.status_code),
|
||||
),
|
||||
)
|
||||
end
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
if data["error"]
|
||||
fatal_error(
|
||||
format_tags(
|
||||
"failed to get user #{kind}",
|
||||
make_tags(error: data["error"]),
|
||||
),
|
||||
)
|
||||
end
|
||||
subjects_data.concat(
|
||||
data[bsky_field].map do |subject_data|
|
||||
Bluesky::Graph::Subject.from_json(subject_data)
|
||||
end,
|
||||
)
|
||||
cursor = data["cursor"]
|
||||
break if cursor.nil?
|
||||
end
|
||||
|
||||
handle_subjects_data(
|
||||
user,
|
||||
subjects_data,
|
||||
scan,
|
||||
edge_name:,
|
||||
user_attr:,
|
||||
other_attr:,
|
||||
)
|
||||
scan.update!(state: "completed", completed_at: Time.current)
|
||||
logger.info(
|
||||
format_tags(
|
||||
"completed user #{kind} scan",
|
||||
make_tags(num_subjects: subjects_data.size),
|
||||
),
|
||||
)
|
||||
rescue => e
|
||||
scan.update!(state: "error", completed_at: Time.current) if scan
|
||||
raise e
|
||||
end
|
||||
|
||||
sig do
|
||||
params(
|
||||
user: Domain::User::BlueskyUser,
|
||||
subjects: T::Array[Bluesky::Graph::Subject],
|
||||
scan: Domain::UserJobEvent::FollowScan,
|
||||
edge_name: Symbol,
|
||||
user_attr: Symbol,
|
||||
other_attr: Symbol,
|
||||
).void
|
||||
end
|
||||
def handle_subjects_data(
|
||||
user,
|
||||
subjects,
|
||||
scan,
|
||||
edge_name:,
|
||||
user_attr:,
|
||||
other_attr:
|
||||
)
|
||||
subjects_by_did =
|
||||
T.cast(subjects.index_by(&:did), T::Hash[String, Bluesky::Graph::Subject])
|
||||
|
||||
users_by_did =
|
||||
T.cast(
|
||||
Domain::User::BlueskyUser.where(did: subjects_by_did.keys).index_by(
|
||||
&:did
|
||||
),
|
||||
T::Hash[String, Domain::User::BlueskyUser],
|
||||
)
|
||||
|
||||
missing_user_dids = subjects_by_did.keys - users_by_did.keys
|
||||
missing_user_dids.each do |did|
|
||||
subject = subjects_by_did[did] || next
|
||||
users_by_did[did] = create_user_from_subject(subject)
|
||||
end
|
||||
|
||||
users_by_id = users_by_did.values.map { |u| [T.must(u.id), u] }.to_h
|
||||
|
||||
existing_subject_ids =
|
||||
T.cast(user.send(edge_name).pluck(other_attr), T::Array[Integer])
|
||||
|
||||
new_user_ids = users_by_did.values.map(&:id).compact - existing_subject_ids
|
||||
removed_user_ids =
|
||||
existing_subject_ids - users_by_did.values.map(&:id).compact
|
||||
|
||||
follow_upsert_attrs = []
|
||||
unfollow_upsert_attrs = []
|
||||
referenced_user_ids = Set.new([user.id])
|
||||
|
||||
new_user_ids.each do |new_user_id|
|
||||
new_user_did = users_by_id[new_user_id]&.did
|
||||
followed_at = new_user_did && subjects_by_did[new_user_did]&.created_at
|
||||
referenced_user_ids.add(new_user_id)
|
||||
follow_upsert_attrs << {
|
||||
user_attr => user.id,
|
||||
other_attr => new_user_id,
|
||||
:followed_at => followed_at,
|
||||
:removed_at => nil,
|
||||
}
|
||||
end
|
||||
|
||||
removed_at = Time.current
|
||||
removed_user_ids.each do |removed_user_id|
|
||||
referenced_user_ids.add(removed_user_id)
|
||||
unfollow_upsert_attrs << {
|
||||
user_attr => user.id,
|
||||
other_attr => removed_user_id,
|
||||
:removed_at => removed_at,
|
||||
}
|
||||
end
|
||||
|
||||
Domain::User.transaction do
|
||||
follow_upsert_attrs.each_slice(5000) do |slice|
|
||||
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
|
||||
end
|
||||
unfollow_upsert_attrs.each_slice(5000) do |slice|
|
||||
Domain::UserUserFollow.upsert_all(slice, unique_by: %i[from_id to_id])
|
||||
end
|
||||
end
|
||||
|
||||
# reset counter caches
|
||||
Domain::User.transaction do
|
||||
referenced_user_ids.each do |user_id|
|
||||
Domain::User.reset_counters(
|
||||
user_id,
|
||||
:user_user_follows_from,
|
||||
:user_user_follows_to,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
update_attrs = {
|
||||
num_created_users: missing_user_dids.size,
|
||||
num_existing_assocs: existing_subject_ids.size,
|
||||
num_new_assocs: new_user_ids.size,
|
||||
num_removed_assocs: removed_user_ids.size,
|
||||
num_total_assocs: subjects.size,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
format_tags("updated user #{edge_name}", make_tags(update_attrs)),
|
||||
)
|
||||
scan.update_json_attributes!(update_attrs)
|
||||
end
|
||||
|
||||
sig do
|
||||
params(subject: Bluesky::Graph::Subject).returns(Domain::User::BlueskyUser)
|
||||
end
|
||||
def create_user_from_subject(subject)
|
||||
user =
|
||||
Domain::User::BlueskyUser.create!(
|
||||
did: subject.did,
|
||||
handle: subject.handle,
|
||||
display_name: subject.display_name,
|
||||
description: subject.description,
|
||||
)
|
||||
avatar = user.create_avatar(url_str: subject.avatar)
|
||||
defer_job(Domain::Bluesky::Job::ScanUserJob, { user: }, { priority: 0 })
|
||||
defer_job(Domain::UserAvatarJob, { avatar: }, { priority: -1 })
|
||||
user
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,6 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
|
||||
logger.push_tags(make_arg_tag(user))
|
||||
logger.info(format_tags("starting profile scan"))
|
||||
|
||||
return if buggy_user?(user)
|
||||
if !user.profile_scan.due? && !force_scan?
|
||||
logger.info(
|
||||
format_tags(
|
||||
@@ -16,12 +15,10 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
|
||||
make_tags(scanned_at: user.profile_scan.ago_in_words),
|
||||
),
|
||||
)
|
||||
enqueue_scan_posts_job_if_due(user)
|
||||
return
|
||||
end
|
||||
|
||||
scan_user_profile(user)
|
||||
enqueue_scan_posts_job_if_due(user)
|
||||
logger.info(format_tags("completed profile scan"))
|
||||
ensure
|
||||
user.save! if user
|
||||
@@ -55,6 +52,7 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
|
||||
sig { params(user: Domain::User::BlueskyUser).void }
|
||||
def scan_user_profile(user)
|
||||
logger.info(format_tags("scanning user profile"))
|
||||
profile_scan = Domain::UserJobEvent::ProfileScan.create!(user:)
|
||||
|
||||
# Use AT Protocol API to get user profile
|
||||
profile_url =
|
||||
@@ -62,6 +60,7 @@ class Domain::Bluesky::Job::ScanUserJob < Domain::Bluesky::Job::Base
|
||||
|
||||
response = http_client.get(profile_url)
|
||||
user.last_scan_log_entry = response.log_entry
|
||||
profile_scan.update!(log_entry: response.log_entry)
|
||||
|
||||
if response.status_code != 200
|
||||
fatal_error(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
class Domain::StaticFileJob < Scraper::JobBase
|
||||
include Domain::StaticFileJobHelper
|
||||
queue_as :static_file
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
sig { override.returns(Symbol) }
|
||||
def self.http_factory_method
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# typed: strict
|
||||
class Domain::UserAvatarJob < Scraper::JobBase
|
||||
queue_as :static_file
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
sig { override.returns(Symbol) }
|
||||
def self.http_factory_method
|
||||
@@ -43,6 +44,10 @@ class Domain::UserAvatarJob < Scraper::JobBase
|
||||
end
|
||||
end
|
||||
ensure
|
||||
avatar.save! if avatar
|
||||
if avatar
|
||||
avatar.save!
|
||||
user = avatar.user
|
||||
user.touch if user
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
7
app/lib/bluesky/graph.rb
Normal file
7
app/lib/bluesky/graph.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Bluesky
|
||||
module Graph
|
||||
end
|
||||
end
|
||||
31
app/lib/bluesky/graph/subject.rb
Normal file
31
app/lib/bluesky/graph/subject.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Bluesky
|
||||
module Graph
|
||||
class Subject < T::ImmutableStruct
|
||||
extend T::Sig
|
||||
include T::Struct::ActsAsComparable
|
||||
const :did, String
|
||||
const :handle, String
|
||||
const :display_name, T.nilable(String)
|
||||
const :description, T.nilable(String)
|
||||
const :avatar, T.nilable(String)
|
||||
const :indexed_at, T.nilable(Time)
|
||||
const :created_at, T.nilable(Time)
|
||||
|
||||
sig { params(json: T::Hash[String, T.untyped]).returns(Subject) }
|
||||
def self.from_json(json)
|
||||
new(
|
||||
did: json["did"],
|
||||
handle: json["handle"],
|
||||
display_name: json["displayName"],
|
||||
description: json["description"],
|
||||
avatar: json["avatar"],
|
||||
indexed_at: (ia = json["indexedAt"]) && Time.zone.parse(ia),
|
||||
created_at: (ca = json["createdAt"]) && Time.zone.parse(ca),
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -127,6 +127,16 @@ class Domain::User < ReduxApplicationRecord
|
||||
has_many :followed_users, through: :user_user_follows_from, source: :to
|
||||
has_many :followed_by_users, through: :user_user_follows_to, source: :from
|
||||
|
||||
has_many :follows_scans,
|
||||
-> { where(kind: "follows").order(created_at: :asc) },
|
||||
inverse_of: :user,
|
||||
class_name: "Domain::UserJobEvent::FollowScan"
|
||||
|
||||
has_many :followed_by_scans,
|
||||
-> { where(kind: "followed_by").order(created_at: :asc) },
|
||||
inverse_of: :user,
|
||||
class_name: "Domain::UserJobEvent::FollowScan"
|
||||
|
||||
sig { params(klass: T.class_of(Domain::Post)).void }
|
||||
def self.has_created_posts!(klass)
|
||||
self.class_has_created_posts = klass
|
||||
|
||||
@@ -8,6 +8,9 @@ class Domain::User::BlueskyUser < Domain::User
|
||||
has_created_posts! Domain::Post::BlueskyPost
|
||||
# TODO - when we scrape liked posts, add this back in
|
||||
# has_faved_posts! Domain::Post::BlueskyPost
|
||||
#
|
||||
has_followed_users! Domain::User::BlueskyUser
|
||||
has_followed_by_users! Domain::User::BlueskyUser
|
||||
|
||||
belongs_to :last_scan_log_entry, class_name: "HttpLogEntry", optional: true
|
||||
belongs_to :last_posts_scan_log_entry,
|
||||
|
||||
@@ -6,5 +6,5 @@ class Domain::UserJobEvent < ReduxApplicationRecord
|
||||
abstract!
|
||||
self.abstract_class = true
|
||||
|
||||
belongs_to :user, class_name: Domain::User.name
|
||||
belongs_to :user, class_name: "Domain::User"
|
||||
end
|
||||
|
||||
32
app/models/domain/user_job_event/follow_scan.rb
Normal file
32
app/models/domain/user_job_event/follow_scan.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Domain::UserJobEvent::FollowScan < Domain::UserJobEvent
|
||||
self.table_name = "domain_user_job_event_follow_scans"
|
||||
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
|
||||
enum :state,
|
||||
{ running: "running", error: "error", completed: "completed" },
|
||||
prefix: true
|
||||
enum :kind, { followed_by: "followed_by", follows: "follows" }, prefix: true
|
||||
|
||||
validates :state,
|
||||
presence: true,
|
||||
inclusion: {
|
||||
in: %w[running error completed],
|
||||
}
|
||||
validates :kind, presence: true, inclusion: { in: %w[followed_by follows] }
|
||||
validates :started_at, presence: true
|
||||
validates :completed_at, presence: true, unless: :state_running?
|
||||
validates :log_entry, presence: true, if: :state_completed?
|
||||
|
||||
before_validation do
|
||||
self.state ||= "running" if new_record?
|
||||
self.started_at ||= Time.current if new_record?
|
||||
end
|
||||
|
||||
sig { params(json_attributes: T::Hash[Symbol, T.untyped]).void }
|
||||
def update_json_attributes!(json_attributes)
|
||||
self.json_attributes = json_attributes.merge(self.json_attributes)
|
||||
save!
|
||||
end
|
||||
end
|
||||
4
app/models/domain/user_job_event/posts_scan.rb
Normal file
4
app/models/domain/user_job_event/posts_scan.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Domain::UserJobEvent::PostsScan < Domain::UserJobEvent
|
||||
self.table_name = "domain_user_job_event_posts_scans"
|
||||
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
|
||||
end
|
||||
4
app/models/domain/user_job_event/profile_scan.rb
Normal file
4
app/models/domain/user_job_event/profile_scan.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class Domain::UserJobEvent::ProfileScan < Domain::UserJobEvent
|
||||
self.table_name = "domain_user_job_event_profile_scans"
|
||||
belongs_to :log_entry, class_name: "HttpLogEntry", optional: true
|
||||
end
|
||||
@@ -64,7 +64,7 @@ class ReduxApplicationRecord < ActiveRecord::Base
|
||||
end
|
||||
|
||||
after_save do
|
||||
T.bind(self, ReduxApplicationRecord)
|
||||
# T.bind(self, ReduxApplicationRecord)
|
||||
@after_save_deferred_jobs ||=
|
||||
T.let([], T.nilable(T::Array[[DeferredJob, T.nilable(Scraper::JobBase)]]))
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<% if user.display_name.present? %>
|
||||
<span class="text-slate-800 text-lg font-semibold">
|
||||
<% if user.display_name.present? %>
|
||||
<div class="flex flex-col mb-1">
|
||||
<span class="text-slate-800 text-lg font-semibold leading-none">
|
||||
<%= user.display_name %>
|
||||
</span>
|
||||
<span class="text-sm font-normal text-slate-500" title="<%= user.did %>">
|
||||
@<%= user.handle %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-slate-800 text-lg font-semibold" title="<%= user.did %>">
|
||||
<%= user.handle %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-slate-800 text-lg font-semibold" title="<%= user.did %>">
|
||||
<%= user.handle %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
<% case @index_type %>
|
||||
<% when :followed_by %>
|
||||
<%= pluralize(@users.total_count, "user") %> following
|
||||
<%= link_to @user.name,
|
||||
<%= link_to @user.name_for_view,
|
||||
domain_user_following_path(@user),
|
||||
class: "text-blue-600 hover:underline" %>
|
||||
<% when :following %>
|
||||
<%= pluralize(@users.total_count, "user") %> followed by
|
||||
<%= link_to @user.name,
|
||||
<%= link_to @user.name_for_view,
|
||||
domain_user_following_path(@user),
|
||||
class: "text-blue-600 hover:underline" %>
|
||||
<% when :users_faving_post %>
|
||||
@@ -36,17 +36,21 @@
|
||||
<% if user.avatar&.log_entry.present? %>
|
||||
<%= image_tag domain_user_avatar_img_src_path(user.avatar, thumb: "64-avatar"),
|
||||
class: "h-12 w-12 rounded-md border object-cover",
|
||||
alt: user.name %>
|
||||
alt: user.name_for_view %>
|
||||
<% else %>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-slate-200"
|
||||
>
|
||||
<i class="bi bi-person text-slate-400"></i>
|
||||
<i class="fa-solid fa-user text-slate-400"></i>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-slate-900"><%= user.name %></div>
|
||||
<div class="text-sm text-slate-500">@<%= user.url_name %></div>
|
||||
<div class="font-medium text-slate-900"><%= user.name_for_view %></div>
|
||||
<% if user.is_a?(Domain::User::BlueskyUser) %>
|
||||
<div class="text-sm text-slate-500">@<%= user.handle %></div>
|
||||
<% elsif user.is_a?(Domain::User::FaUse) %>
|
||||
<div class="text-sm text-slate-500">@<%= user.url_name %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
class CreateDomainUserJobEventProfileScans < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def change
|
||||
create_table :domain_user_job_event_profile_scans do |t|
|
||||
t.references :user, null: false, foreign_key: { to_table: :domain_users }
|
||||
t.references :log_entry,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
},
|
||||
index: false
|
||||
t.jsonb :json_attributes, null: false, default: {}
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateDomainUserJobEventPostsScans < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def change
|
||||
create_table :domain_user_job_event_posts_scans do |t|
|
||||
t.references :user, null: false, foreign_key: { to_table: :domain_users }
|
||||
t.references :log_entry,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
},
|
||||
index: false
|
||||
t.jsonb :json_attributes, null: false, default: {}
|
||||
t.integer :total_posts_seen
|
||||
t.integer :new_posts_seen
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
class CreateDomainUserJobEventFollowScans < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :domain_user_job_event_follow_scans do |t|
|
||||
t.string :kind, null: false
|
||||
t.string :state, null: false, default: "running"
|
||||
t.datetime :started_at, null: false
|
||||
t.datetime :completed_at
|
||||
t.references :user, null: false, foreign_key: { to_table: :domain_users }
|
||||
t.references :log_entry,
|
||||
foreign_key: {
|
||||
to_table: :http_log_entries,
|
||||
},
|
||||
index: false
|
||||
t.jsonb :json_attributes, null: false, default: {}
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddRemovedAtToUserUserFollow < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def change
|
||||
add_column :domain_user_user_follows, :removed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# typed: strict
|
||||
class AddFollowedAtToUserUserFollow < ActiveRecord::Migration[7.2]
|
||||
sig { void }
|
||||
def change
|
||||
add_column :domain_user_user_follows, :followed_at, :datetime
|
||||
end
|
||||
end
|
||||
228
db/structure.sql
228
db/structure.sql
@@ -1816,6 +1816,111 @@ CREATE SEQUENCE public.domain_user_job_event_add_tracked_objects_id_seq
|
||||
ALTER SEQUENCE public.domain_user_job_event_add_tracked_objects_id_seq OWNED BY public.domain_user_job_event_add_tracked_objects.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.domain_user_job_event_follow_scans (
|
||||
id bigint NOT NULL,
|
||||
kind character varying NOT NULL,
|
||||
state character varying DEFAULT 'running'::character varying NOT NULL,
|
||||
started_at timestamp(6) without time zone NOT NULL,
|
||||
completed_at timestamp(6) without time zone,
|
||||
user_id bigint NOT NULL,
|
||||
log_entry_id bigint,
|
||||
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.domain_user_job_event_follow_scans_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.domain_user_job_event_follow_scans_id_seq OWNED BY public.domain_user_job_event_follow_scans.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.domain_user_job_event_posts_scans (
|
||||
id bigint NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
log_entry_id bigint,
|
||||
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
total_posts_seen integer,
|
||||
new_posts_seen integer,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.domain_user_job_event_posts_scans_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.domain_user_job_event_posts_scans_id_seq OWNED BY public.domain_user_job_event_posts_scans.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE public.domain_user_job_event_profile_scans (
|
||||
id bigint NOT NULL,
|
||||
user_id bigint NOT NULL,
|
||||
log_entry_id bigint,
|
||||
json_attributes jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
created_at timestamp(6) without time zone NOT NULL,
|
||||
updated_at timestamp(6) without time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans_id_seq; Type: SEQUENCE; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE public.domain_user_job_event_profile_scans_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE public.domain_user_job_event_profile_scans_id_seq OWNED BY public.domain_user_job_event_profile_scans.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_post_creations; Type: TABLE; Schema: public; Owner: -
|
||||
--
|
||||
@@ -1918,7 +2023,9 @@ CREATE TABLE public.domain_user_user_follow_to_factors (
|
||||
|
||||
CREATE TABLE public.domain_user_user_follows (
|
||||
from_id bigint NOT NULL,
|
||||
to_id bigint NOT NULL
|
||||
to_id bigint NOT NULL,
|
||||
removed_at timestamp(6) without time zone,
|
||||
followed_at timestamp(6) without time zone
|
||||
);
|
||||
|
||||
|
||||
@@ -3187,6 +3294,27 @@ ALTER TABLE ONLY public.domain_user_avatars ALTER COLUMN id SET DEFAULT nextval(
|
||||
ALTER TABLE ONLY public.domain_user_job_event_add_tracked_objects ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_add_tracked_objects_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_follow_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_follow_scans_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_posts_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_posts_scans_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_profile_scans ALTER COLUMN id SET DEFAULT nextval('public.domain_user_job_event_profile_scans_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_search_names id; Type: DEFAULT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -3451,6 +3579,30 @@ ALTER TABLE ONLY public.domain_user_job_event_add_tracked_objects
|
||||
ADD CONSTRAINT domain_user_job_event_add_tracked_objects_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans domain_user_job_event_follow_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
|
||||
ADD CONSTRAINT domain_user_job_event_follow_scans_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans domain_user_job_event_posts_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
|
||||
ADD CONSTRAINT domain_user_job_event_posts_scans_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans domain_user_job_event_profile_scans_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
|
||||
ADD CONSTRAINT domain_user_job_event_profile_scans_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_search_names domain_user_search_names_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -4537,6 +4689,27 @@ CREATE INDEX index_domain_user_avatars_on_user_id ON public.domain_user_avatars
|
||||
CREATE INDEX index_domain_user_job_event_add_tracked_objects_on_user_id ON public.domain_user_job_event_add_tracked_objects USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_user_job_event_follow_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_domain_user_job_event_follow_scans_on_user_id ON public.domain_user_job_event_follow_scans USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_user_job_event_posts_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_domain_user_job_event_posts_scans_on_user_id ON public.domain_user_job_event_posts_scans USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_user_job_event_profile_scans_on_user_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX index_domain_user_job_event_profile_scans_on_user_id ON public.domain_user_job_event_profile_scans USING btree (user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: index_domain_user_post_creations_on_post_id_and_user_id; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5573,6 +5746,14 @@ ALTER TABLE ONLY public.domain_post_group_joins
|
||||
ADD CONSTRAINT fk_rails_22154fb920 FOREIGN KEY (post_id) REFERENCES public.domain_posts(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans fk_rails_26cf4d0529; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
|
||||
ADD CONSTRAINT fk_rails_26cf4d0529 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_twitter_medias fk_rails_278c1d09f0; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5597,6 +5778,14 @@ ALTER TABLE ONLY public.domain_users_inkbunny_aux
|
||||
ADD CONSTRAINT fk_rails_304ea0307f FOREIGN KEY (base_table_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans fk_rails_3641cc46f0; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
|
||||
ADD CONSTRAINT fk_rails_3641cc46f0 FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_ib_aux fk_rails_3762390d41; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5709,6 +5898,14 @@ ALTER TABLE ONLY public.domain_user_search_names
|
||||
ADD CONSTRAINT fk_rails_8475fe75b5 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans fk_rails_94b2c9f6fb; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
|
||||
ADD CONSTRAINT fk_rails_94b2c9f6fb FOREIGN KEY (log_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: good_job_execution_log_lines_collections fk_rails_98c288034f; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5773,6 +5970,22 @@ ALTER TABLE ONLY public.domain_posts_ib_aux
|
||||
ADD CONSTRAINT fk_rails_b94b311254 FOREIGN KEY (base_table_id) REFERENCES public.domain_posts(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_profile_scans fk_rails_b9a4299a45; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_profile_scans
|
||||
ADD CONSTRAINT fk_rails_b9a4299a45 FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_posts_scans fk_rails_ba009ed77e; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_posts_scans
|
||||
ADD CONSTRAINT fk_rails_ba009ed77e FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_posts_fa_aux fk_rails_be2be2e955; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5853,6 +6066,14 @@ ALTER TABLE ONLY public.domain_posts_e621_aux
|
||||
ADD CONSTRAINT fk_rails_d691739802 FOREIGN KEY (caused_by_entry_id) REFERENCES public.http_log_entries(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_user_job_event_follow_scans fk_rails_ea2f8b74ab; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.domain_user_job_event_follow_scans
|
||||
ADD CONSTRAINT fk_rails_ea2f8b74ab FOREIGN KEY (user_id) REFERENCES public.domain_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: domain_post_group_joins fk_rails_eddd0a9390; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||
--
|
||||
@@ -5892,6 +6113,11 @@ ALTER TABLE ONLY public.domain_twitter_tweets
|
||||
SET search_path TO "$user", public;
|
||||
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20250814165718'),
|
||||
('20250814152837'),
|
||||
('20250813190947'),
|
||||
('20250813185746'),
|
||||
('20250813185603'),
|
||||
('20250813000020'),
|
||||
('20250812215907'),
|
||||
('20250812214902'),
|
||||
|
||||
27
sorbet/rbi/dsl/domain/bluesky/job/scan_user_follows_job.rbi
generated
Normal file
27
sorbet/rbi/dsl/domain/bluesky/job/scan_user_follows_job.rbi
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
# typed: true
|
||||
|
||||
# DO NOT EDIT MANUALLY
|
||||
# This is an autogenerated file for dynamic methods in `Domain::Bluesky::Job::ScanUserFollowsJob`.
|
||||
# Please instead update this file by running `bin/tapioca dsl Domain::Bluesky::Job::ScanUserFollowsJob`.
|
||||
|
||||
|
||||
class Domain::Bluesky::Job::ScanUserFollowsJob
|
||||
sig { returns(ColorLogger) }
|
||||
def logger; end
|
||||
|
||||
class << self
|
||||
sig { returns(ColorLogger) }
|
||||
def logger; end
|
||||
|
||||
sig do
|
||||
params(
|
||||
args: T::Hash[::Symbol, T.untyped],
|
||||
block: T.nilable(T.proc.params(job: Domain::Bluesky::Job::ScanUserFollowsJob).void)
|
||||
).returns(T.any(Domain::Bluesky::Job::ScanUserFollowsJob, FalseClass))
|
||||
end
|
||||
def perform_later(args, &block); end
|
||||
|
||||
sig { params(args: T::Hash[::Symbol, T.untyped]).returns(T.untyped) }
|
||||
def perform_now(args); end
|
||||
end
|
||||
end
|
||||
28
sorbet/rbi/dsl/domain/user.rbi
generated
28
sorbet/rbi/dsl/domain/user.rbi
generated
@@ -491,6 +491,20 @@ class Domain::User
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
|
||||
def favs_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
@@ -519,6 +533,20 @@ class Domain::User
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def post_ids; end
|
||||
|
||||
|
||||
40
sorbet/rbi/dsl/domain/user/bluesky_user.rbi
generated
40
sorbet/rbi/dsl/domain/user/bluesky_user.rbi
generated
@@ -591,18 +591,32 @@ class Domain::User::BlueskyUser
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
|
||||
def favs_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_users, through: :user_user_follows_to`.
|
||||
# This method is created by ActiveRecord on the `Domain::User::BlueskyUser` class because it declared `has_many :followed_by_users, through: :user_user_follows_to`.
|
||||
# 🔗 [Rails guide for `has_many_through` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association)
|
||||
sig { returns(::Domain::User::PrivateCollectionProxy) }
|
||||
sig { returns(::Domain::User::BlueskyUser::PrivateCollectionProxy) }
|
||||
def followed_by_users; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
sig { params(value: T::Enumerable[::Domain::User::BlueskyUser]).void }
|
||||
def followed_by_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
@@ -611,14 +625,28 @@ class Domain::User::BlueskyUser
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_user_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_users, through: :user_user_follows_from`.
|
||||
# This method is created by ActiveRecord on the `Domain::User::BlueskyUser` class because it declared `has_many :followed_users, through: :user_user_follows_from`.
|
||||
# 🔗 [Rails guide for `has_many_through` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-through-association)
|
||||
sig { returns(::Domain::User::PrivateCollectionProxy) }
|
||||
sig { returns(::Domain::User::BlueskyUser::PrivateCollectionProxy) }
|
||||
def followed_users; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
sig { params(value: T::Enumerable[::Domain::User::BlueskyUser]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T.nilable(::HttpLogEntry)) }
|
||||
def last_posts_scan_log_entry; end
|
||||
|
||||
|
||||
28
sorbet/rbi/dsl/domain/user/e621_user.rbi
generated
28
sorbet/rbi/dsl/domain/user/e621_user.rbi
generated
@@ -556,6 +556,20 @@ class Domain::User::E621User
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
|
||||
def favs_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
@@ -584,6 +598,20 @@ class Domain::User::E621User
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def post_ids; end
|
||||
|
||||
|
||||
28
sorbet/rbi/dsl/domain/user/fa_user.rbi
generated
28
sorbet/rbi/dsl/domain/user/fa_user.rbi
generated
@@ -595,6 +595,20 @@ class Domain::User::FaUser
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
|
||||
def favs_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
@@ -623,6 +637,20 @@ class Domain::User::FaUser
|
||||
sig { params(value: T::Enumerable[::Domain::User::FaUser]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T.nilable(::HttpLogEntry)) }
|
||||
def last_gallery_page_log_entry; end
|
||||
|
||||
|
||||
28
sorbet/rbi/dsl/domain/user/inkbunny_user.rbi
generated
28
sorbet/rbi/dsl/domain/user/inkbunny_user.rbi
generated
@@ -567,6 +567,20 @@ class Domain::User::InkbunnyUser
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::AddTrackedObject]).void }
|
||||
def favs_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
@@ -595,6 +609,20 @@ class Domain::User::InkbunnyUser
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T.nilable(::DomainUsersInkbunnyAux)) }
|
||||
def inkbunny_aux; end
|
||||
|
||||
|
||||
28
sorbet/rbi/dsl/domain/user/sofurry_user.rbi
generated
28
sorbet/rbi/dsl/domain/user/sofurry_user.rbi
generated
@@ -680,6 +680,20 @@ class Domain::User::SofurryUser
|
||||
sig { params(value: T::Enumerable[::Domain::PostGroup::SofurryFolder]).void }
|
||||
def folders=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def followed_by_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :followed_by_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def followed_by_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def followed_by_scans=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def followed_by_user_ids; end
|
||||
|
||||
@@ -708,6 +722,20 @@ class Domain::User::SofurryUser
|
||||
sig { params(value: T::Enumerable[::Domain::User]).void }
|
||||
def followed_users=(value); end
|
||||
|
||||
sig { returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids; end
|
||||
|
||||
sig { params(ids: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
|
||||
def follows_scan_ids=(ids); end
|
||||
|
||||
# This method is created by ActiveRecord on the `Domain::User` class because it declared `has_many :follows_scans`.
|
||||
# 🔗 [Rails guide for `has_many` association](https://guides.rubyonrails.org/association_basics.html#the-has-many-association)
|
||||
sig { returns(::Domain::UserJobEvent::FollowScan::PrivateCollectionProxy) }
|
||||
def follows_scans; end
|
||||
|
||||
sig { params(value: T::Enumerable[::Domain::UserJobEvent::FollowScan]).void }
|
||||
def follows_scans=(value); end
|
||||
|
||||
sig { returns(T.nilable(::HttpLogEntry)) }
|
||||
def last_scan_log_entry; end
|
||||
|
||||
|
||||
1826
sorbet/rbi/dsl/domain/user_job_event/follow_scan.rbi
generated
Normal file
1826
sorbet/rbi/dsl/domain/user_job_event/follow_scan.rbi
generated
Normal file
File diff suppressed because it is too large
Load Diff
1573
sorbet/rbi/dsl/domain/user_job_event/posts_scan.rbi
generated
Normal file
1573
sorbet/rbi/dsl/domain/user_job_event/posts_scan.rbi
generated
Normal file
File diff suppressed because it is too large
Load Diff
1459
sorbet/rbi/dsl/domain/user_job_event/profile_scan.rbi
generated
Normal file
1459
sorbet/rbi/dsl/domain/user_job_event/profile_scan.rbi
generated
Normal file
File diff suppressed because it is too large
Load Diff
134
sorbet/rbi/dsl/domain/user_user_follow.rbi
generated
134
sorbet/rbi/dsl/domain/user_user_follow.rbi
generated
@@ -646,6 +646,61 @@ class Domain::UserUserFollow
|
||||
end
|
||||
|
||||
module GeneratedAttributeMethods
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at; end
|
||||
|
||||
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at=(value); end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def followed_at?; end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at_before_last_save; end
|
||||
|
||||
sig { returns(T.untyped) }
|
||||
def followed_at_before_type_cast; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def followed_at_came_from_user?; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def followed_at_change; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def followed_at_change_to_be_saved; end
|
||||
|
||||
sig do
|
||||
params(
|
||||
from: T.nilable(::ActiveSupport::TimeWithZone),
|
||||
to: T.nilable(::ActiveSupport::TimeWithZone)
|
||||
).returns(T::Boolean)
|
||||
end
|
||||
def followed_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at_in_database; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def followed_at_previous_change; end
|
||||
|
||||
sig do
|
||||
params(
|
||||
from: T.nilable(::ActiveSupport::TimeWithZone),
|
||||
to: T.nilable(::ActiveSupport::TimeWithZone)
|
||||
).returns(T::Boolean)
|
||||
end
|
||||
def followed_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at_previously_was; end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def followed_at_was; end
|
||||
|
||||
sig { void }
|
||||
def followed_at_will_change!; end
|
||||
|
||||
sig { returns(T.nilable(::Integer)) }
|
||||
def from_id; end
|
||||
|
||||
@@ -756,15 +811,82 @@ class Domain::UserUserFollow
|
||||
sig { void }
|
||||
def id_will_change!; end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at; end
|
||||
|
||||
sig { params(value: T.nilable(::ActiveSupport::TimeWithZone)).returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at=(value); end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def removed_at?; end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at_before_last_save; end
|
||||
|
||||
sig { returns(T.untyped) }
|
||||
def removed_at_before_type_cast; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def removed_at_came_from_user?; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def removed_at_change; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def removed_at_change_to_be_saved; end
|
||||
|
||||
sig do
|
||||
params(
|
||||
from: T.nilable(::ActiveSupport::TimeWithZone),
|
||||
to: T.nilable(::ActiveSupport::TimeWithZone)
|
||||
).returns(T::Boolean)
|
||||
end
|
||||
def removed_at_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at_in_database; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def removed_at_previous_change; end
|
||||
|
||||
sig do
|
||||
params(
|
||||
from: T.nilable(::ActiveSupport::TimeWithZone),
|
||||
to: T.nilable(::ActiveSupport::TimeWithZone)
|
||||
).returns(T::Boolean)
|
||||
end
|
||||
def removed_at_previously_changed?(from: T.unsafe(nil), to: T.unsafe(nil)); end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at_previously_was; end
|
||||
|
||||
sig { returns(T.nilable(::ActiveSupport::TimeWithZone)) }
|
||||
def removed_at_was; end
|
||||
|
||||
sig { void }
|
||||
def removed_at_will_change!; end
|
||||
|
||||
sig { void }
|
||||
def restore_followed_at!; end
|
||||
|
||||
sig { void }
|
||||
def restore_from_id!; end
|
||||
|
||||
sig { void }
|
||||
def restore_id!; end
|
||||
|
||||
sig { void }
|
||||
def restore_removed_at!; end
|
||||
|
||||
sig { void }
|
||||
def restore_to_id!; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def saved_change_to_followed_at; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def saved_change_to_followed_at?; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
|
||||
def saved_change_to_from_id; end
|
||||
|
||||
@@ -779,6 +901,12 @@ class Domain::UserUserFollow
|
||||
sig { returns(T::Boolean) }
|
||||
def saved_change_to_id?; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::ActiveSupport::TimeWithZone), T.nilable(::ActiveSupport::TimeWithZone)])) }
|
||||
def saved_change_to_removed_at; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def saved_change_to_removed_at?; end
|
||||
|
||||
sig { returns(T.nilable([T.nilable(::Integer), T.nilable(::Integer)])) }
|
||||
def saved_change_to_to_id; end
|
||||
|
||||
@@ -830,12 +958,18 @@ class Domain::UserUserFollow
|
||||
sig { void }
|
||||
def to_id_will_change!; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def will_save_change_to_followed_at?; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def will_save_change_to_from_id?; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def will_save_change_to_id?; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def will_save_change_to_removed_at?; end
|
||||
|
||||
sig { returns(T::Boolean) }
|
||||
def will_save_change_to_to_id?; end
|
||||
end
|
||||
|
||||
@@ -92,7 +92,7 @@ class HttpClientMockHelpers
|
||||
build(
|
||||
:blob_file,
|
||||
content_type: request[:content_type],
|
||||
contents: request[:contents],
|
||||
contents: request[:contents].dup,
|
||||
),
|
||||
)
|
||||
log_entry.save!
|
||||
|
||||
248
spec/jobs/domain/bluesky/job/scan_user_follows_job_spec.rb
Normal file
248
spec/jobs/domain/bluesky/job/scan_user_follows_job_spec.rb
Normal file
@@ -0,0 +1,248 @@
|
||||
# typed: false
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Domain::Bluesky::Job::ScanUserFollowsJob do
|
||||
include PerformJobHelpers
|
||||
|
||||
let(:user) do
|
||||
create(
|
||||
:domain_user_bluesky_user,
|
||||
did: "did:plc:test123",
|
||||
handle: "testuser.bsky.social",
|
||||
)
|
||||
end
|
||||
|
||||
describe "#perform" do
|
||||
context "when fetching follows succeeds" do
|
||||
let(:first_follows_response_body) do
|
||||
{
|
||||
"cursor" => "next_page_cursor",
|
||||
"follows" => [
|
||||
{
|
||||
"did" => "did:plc:follow1",
|
||||
"handle" => "follow1.bsky.social",
|
||||
"displayName" => "Follow One",
|
||||
"description" => "First follower",
|
||||
"createdAt" => "2025-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"did" => "did:plc:follow2",
|
||||
"handle" => "follow2.bsky.social",
|
||||
"displayName" => "Follow Two",
|
||||
"description" => "Second follower",
|
||||
"createdAt" => "2025-01-02T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:second_follows_response_body) do
|
||||
{
|
||||
"cursor" => nil,
|
||||
"follows" => [
|
||||
{
|
||||
"did" => "did:plc:follow3",
|
||||
"handle" => "follow3.bsky.social",
|
||||
"displayName" => "Follow Three",
|
||||
"description" => "Third follower",
|
||||
"createdAt" => "2025-01-03T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:first_followers_response_body) do
|
||||
{
|
||||
"cursor" => nil,
|
||||
"followers" => [
|
||||
{
|
||||
"did" => "did:plc:follow5",
|
||||
"handle" => "follow5.bsky.social",
|
||||
"displayName" => "Follow Five",
|
||||
"description" => "Fifth follower",
|
||||
"createdAt" => "2025-01-05T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:client_mock_config) do
|
||||
[
|
||||
{
|
||||
uri:
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
|
||||
status_code: 200,
|
||||
content_type: "application/json",
|
||||
contents: first_follows_response_body,
|
||||
},
|
||||
{
|
||||
uri:
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100&cursor=next_page_cursor",
|
||||
status_code: 200,
|
||||
content_type: "application/json",
|
||||
contents: second_follows_response_body,
|
||||
caused_by_entry_idx: 0,
|
||||
},
|
||||
{
|
||||
uri:
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollowers?actor=#{user.did!}&limit=100",
|
||||
status_code: 200,
|
||||
content_type: "application/json",
|
||||
contents: first_followers_response_body,
|
||||
caused_by_entry_idx: 0,
|
||||
},
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
|
||||
|
||||
# an existing follow that is in the follows scan
|
||||
@follow2 =
|
||||
create(
|
||||
:domain_user_bluesky_user,
|
||||
did: "did:plc:follow2",
|
||||
handle: "follow2.bsky.social",
|
||||
)
|
||||
Domain::UserUserFollow.create!(from: user, to: @follow2)
|
||||
|
||||
# an existing follow that is not in the follows scan
|
||||
@follow4 =
|
||||
create(
|
||||
:domain_user_bluesky_user,
|
||||
did: "did:plc:follow4",
|
||||
handle: "follow4.bsky.social",
|
||||
)
|
||||
Domain::UserUserFollow.create!(from: user, to: @follow4)
|
||||
end
|
||||
|
||||
it "creates a follow scan event" do
|
||||
perform_now({ user: user })
|
||||
scan = user.follows_scans.last
|
||||
expect(scan).to be_present
|
||||
expect(scan.user).to eq(user)
|
||||
expect(scan.log_entry).to eq(@log_entries[0])
|
||||
ja = scan.json_attributes
|
||||
expect(ja["num_created_users"]).to eq(2)
|
||||
expect(ja["num_total_assocs"]).to eq(3)
|
||||
expect(ja["num_existing_assocs"]).to eq(2)
|
||||
expect(ja["num_new_assocs"]).to eq(2)
|
||||
expect(ja["num_removed_assocs"]).to eq(1)
|
||||
end
|
||||
|
||||
it "creates a followed_by scan event" do
|
||||
perform_now({ user: user })
|
||||
scan = user.followed_by_scans.last
|
||||
expect(scan).to be_present
|
||||
expect(scan.user).to eq(user)
|
||||
expect(scan.log_entry).to eq(@log_entries[2])
|
||||
ja = scan.json_attributes
|
||||
expect(ja["num_created_users"]).to eq(1)
|
||||
expect(ja["num_total_assocs"]).to eq(1)
|
||||
expect(ja["num_existing_assocs"]).to eq(0)
|
||||
expect(ja["num_new_assocs"]).to eq(1)
|
||||
expect(ja["num_removed_assocs"]).to eq(0)
|
||||
end
|
||||
|
||||
it "updates the user follows" do
|
||||
expect do
|
||||
perform_now({ user: user })
|
||||
user.reload
|
||||
end.to change { user.reload.user_user_follows_from_count }.from(2).to(4)
|
||||
|
||||
expect(user.user_user_follows_from.where(removed_at: nil).count).to eq(
|
||||
3,
|
||||
)
|
||||
expect(
|
||||
user.user_user_follows_from.where.not(removed_at: nil).pluck(:to_id),
|
||||
).to eq([@follow4.id])
|
||||
end
|
||||
|
||||
it "updates counter caches for all involved users" do
|
||||
perform_now({ user: user })
|
||||
user.reload
|
||||
|
||||
expect(user.user_user_follows_from_count).to eq(4)
|
||||
expect(user.user_user_follows_to_count).to eq(1)
|
||||
|
||||
follow1 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow1")
|
||||
expect(follow1.user_user_follows_from_count).to eq(0)
|
||||
expect(follow1.user_user_follows_to_count).to eq(1)
|
||||
|
||||
follow5 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow5")
|
||||
expect(follow5.user_user_follows_from_count).to eq(1)
|
||||
expect(follow5.user_user_follows_to_count).to eq(0)
|
||||
end
|
||||
|
||||
it "sets the followed_at attribute" do
|
||||
perform_now({ user: user })
|
||||
user.reload
|
||||
follow1 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow1")
|
||||
follow1_assoc = user.user_user_follows_from.find_by(to: follow1)
|
||||
expect(follow1_assoc.followed_at).to eq(
|
||||
Time.parse("2025-01-01T00:00:00Z"),
|
||||
)
|
||||
expect(follow1_assoc.removed_at).to be_nil
|
||||
|
||||
follow4 = Domain::User::BlueskyUser.find_by(did: "did:plc:follow4")
|
||||
follow4_assoc = user.user_user_follows_from.find_by(to: follow4)
|
||||
expect(follow4_assoc.followed_at).to be_nil
|
||||
expect(follow4_assoc.removed_at).to be_within(10.seconds).of(
|
||||
Time.current,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when API returns an error" do
|
||||
let(:error_response_body) { { "error" => "Not found" }.to_json }
|
||||
|
||||
let(:client_mock_config) do
|
||||
[
|
||||
{
|
||||
uri:
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
|
||||
status_code: 200,
|
||||
content_type: "application/json",
|
||||
contents: error_response_body,
|
||||
},
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
|
||||
end
|
||||
|
||||
it "raises a fatal error" do
|
||||
expect { perform_now({ user: user }) }.to raise_error(
|
||||
/failed to get user follows/,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when API returns non-200 status" do
|
||||
let(:client_mock_config) do
|
||||
[
|
||||
{
|
||||
uri:
|
||||
"https://public.api.bsky.app/xrpc/app.bsky.graph.getFollows?actor=#{user.did!}&limit=100",
|
||||
status_code: 404,
|
||||
content_type: "application/json",
|
||||
contents: "Not Found",
|
||||
},
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
@log_entries = HttpClientMockHelpers.init_with(client_mock_config)
|
||||
end
|
||||
|
||||
it "raises a fatal error" do
|
||||
expect { perform_now({ user: user }) }.to raise_error(
|
||||
/failed to get user follows/,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -104,16 +104,16 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
|
||||
expect(user.last_scan_log_entry).to eq(@log_entries.first)
|
||||
end
|
||||
|
||||
it "enqueues ScanPostsJob for posts scanning" do
|
||||
it "does not enqueue a ScanPostsJob" do
|
||||
# Clear any existing enqueued jobs first
|
||||
SpecUtil.clear_enqueued_jobs!
|
||||
|
||||
perform_now({ user: user })
|
||||
|
||||
# Check that ScanPostsJob was enqueued with correct arguments
|
||||
# Check that ScanPostsJob was not enqueued
|
||||
enqueued_jobs =
|
||||
SpecUtil.enqueued_job_args(Domain::Bluesky::Job::ScanPostsJob)
|
||||
expect(enqueued_jobs).to contain_exactly(hash_including(user: user))
|
||||
expect(enqueued_jobs).to be_empty
|
||||
end
|
||||
|
||||
it "creates avatar for user with pending state" do
|
||||
@@ -288,7 +288,6 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
|
||||
|
||||
it "skips scanning if not due" do
|
||||
expect(Scraper::ClientFactory.http_client_mock).not_to receive(:get)
|
||||
|
||||
perform_now({ user: user })
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user