Add avatar downloading to Bluesky scan user job

- Modified process_user_avatar method to enqueue Domain::UserAvatarJob for avatar downloads
- Made Domain::UserAvatarJob concrete (removed abstract!) with generic HTTP client
- Added smart avatar management: handles new avatars, URL changes, and pending re-enqueues
- Added comprehensive test coverage for all avatar scenarios
- Updated HTTP mocking in specs to use HttpClientMockHelpers pattern
- Fixed caused_by_entry handling for chained HTTP requests
- Updated .cursorrules with proper HTTP mocking documentation including caused_by_entry: :any

The job now automatically downloads user avatars when scanning Bluesky users.
This commit is contained in:
Dylan Knutson
2025-08-09 01:23:16 +00:00
parent f2f8a9c34a
commit 5c71fc6b15
6 changed files with 245 additions and 31 deletions

View File

@@ -83,30 +83,32 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
}.to_json
end
before do
# Mock profile API call
expect(http_client_mock).to receive(:get).with(
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{user.did}&collection=app.bsky.actor.profile&rkey=self",
anything,
).and_return(
double(
let(:client_mock_config) do
[
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{user.did}&collection=app.bsky.actor.profile&rkey=self",
status_code: 200,
body: profile_response_body,
log_entry: double,
),
)
content_type: "application/json",
contents: profile_response_body,
},
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
status_code: 200,
content_type: "application/json",
contents: posts_response_body,
caused_by_entry: :any, # Accept any caused_by_entry for the posts call
},
]
end
# Mock posts API call
expect(http_client_mock).to receive(:get).with(
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
anything,
).and_return(
double(
status_code: 200,
body: posts_response_body,
log_entry: double,
),
)
before do
@log_entries =
HttpClientMockHelpers.init_http_client_mock(
http_client_mock,
client_mock_config,
)
# Mock static file job enqueueing - allow it but don't require it
allow(Domain::StaticFileJob).to receive(:perform_later)
@@ -123,7 +125,7 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
expect(user.state).to eq("ok")
end
it "creates avatar for user" do
it "creates avatar for user with pending state" do
expect { perform_now({ user: user }) }.to change {
user.reload.avatar.present?
}.from(false).to(true)
@@ -132,6 +134,21 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
expect(avatar.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiavatar123",
)
expect(avatar.state).to eq("pending")
end
it "enqueues Domain::UserAvatarJob when creating new avatar" do
# Clear any existing enqueued jobs first
SpecUtil.clear_enqueued_jobs!
perform_now({ user: user })
avatar = user.reload.avatar
expect(avatar).to be_present
# Check that UserAvatarJob was enqueued
enqueued_jobs = SpecUtil.enqueued_job_args(Domain::UserAvatarJob)
expect(enqueued_jobs).to contain_exactly(hash_including(avatar: avatar))
end
it "creates posts with media and associated files" do
@@ -166,6 +183,127 @@ RSpec.describe Domain::Bluesky::Job::ScanUserJob do
end
end
context "avatar handling scenarios" do
let(:profile_response_body) do
{
"uri" => "at://#{user.did}/app.bsky.actor.profile/self",
"cid" => "bafyreiabc123",
"value" => {
"displayName" => "Test User",
"description" => "A test user profile",
"avatar" => {
"ref" => {
"$link" => "bafkreiavatar123",
},
"mimeType" => "image/jpeg",
"size" => 50_000,
},
},
}.to_json
end
let(:posts_response_body) { { "records" => [], "cursor" => nil }.to_json }
let(:client_mock_config) do
[
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=#{user.did}&collection=app.bsky.actor.profile&rkey=self",
status_code: 200,
content_type: "application/json",
contents: profile_response_body,
},
{
uri:
"https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=#{user.did}&collection=app.bsky.feed.post&limit=100",
status_code: 200,
content_type: "application/json",
contents: posts_response_body,
caused_by_entry: :any, # Accept any caused_by_entry for the posts call
},
]
end
before do
@log_entries =
HttpClientMockHelpers.init_http_client_mock(
http_client_mock,
client_mock_config,
)
end
context "when user has existing avatar with same URL" do
let!(:existing_avatar) do
user.create_avatar!(
url_str:
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiavatar123",
state: "ok",
)
end
it "does not enqueue job for already downloaded avatar with same URL" do
perform_now({ user: user })
# Avatar should remain unchanged
existing_avatar.reload
expect(existing_avatar.state).to eq("ok")
# Should not enqueue any avatar job
enqueued_jobs = SpecUtil.enqueued_job_args(Domain::UserAvatarJob)
expect(enqueued_jobs).to be_empty
end
end
context "when user has existing avatar with different URL" do
let!(:existing_avatar) do
user.create_avatar!(
url_str:
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=oldavatar456",
state: "ok",
)
end
it "updates avatar URL and enqueues job" do
perform_now({ user: user })
existing_avatar.reload
expect(existing_avatar.url_str).to eq(
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiavatar123",
)
expect(existing_avatar.state).to eq("pending")
# Should enqueue avatar job with the existing avatar
enqueued_jobs = SpecUtil.enqueued_job_args(Domain::UserAvatarJob)
expect(enqueued_jobs).to contain_exactly(
hash_including(avatar: existing_avatar),
)
end
end
context "when user has existing avatar in pending state" do
let!(:existing_avatar) do
user.create_avatar!(
url_str:
"https://bsky.social/xrpc/com.atproto.sync.getBlob?did=#{user.did}&cid=bafkreiavatar123",
state: "pending",
)
end
it "re-enqueues job for pending avatar with same URL" do
perform_now({ user: user })
existing_avatar.reload
expect(existing_avatar.state).to eq("pending")
# Should re-enqueue avatar job
enqueued_jobs = SpecUtil.enqueued_job_args(Domain::UserAvatarJob)
expect(enqueued_jobs).to contain_exactly(
hash_including(avatar: existing_avatar),
)
end
end
end
context "when user already scanned recently" do
before do
user.update!(scanned_profile_at: 1.day.ago, scanned_posts_at: 1.day.ago)