Merge branch 'improved-reachability' into 'develop'

Reachability refactor

See merge request pleroma/pleroma!4366
This commit is contained in:
feld 2025-07-29 21:13:48 +00:00
commit ece089abab
28 changed files with 733 additions and 319 deletions

View file

@ -3,7 +3,6 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Instances.InstanceTest do
alias Pleroma.Instances
alias Pleroma.Instances.Instance
alias Pleroma.Repo
@ -13,8 +12,6 @@ defmodule Pleroma.Instances.InstanceTest do
import ExUnit.CaptureLog
import Pleroma.Factory
setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
describe "set_reachable/1" do
test "clears `unreachable_since` of existing matching Instance record having non-nil `unreachable_since`" do
unreachable_since = NaiveDateTime.to_iso8601(NaiveDateTime.utc_now())
@ -30,6 +27,32 @@ defmodule Pleroma.Instances.InstanceTest do
assert {:ok, instance} = Instance.set_reachable(instance.host)
refute instance.unreachable_since
end
test "cancels all ReachabilityWorker jobs for the domain" do
domain = "cancelme.example.org"
insert(:instance, host: domain, unreachable_since: NaiveDateTime.utc_now())
# Insert a ReachabilityWorker job for this domain, scheduled 5 minutes in the future
scheduled_at = DateTime.add(DateTime.utc_now(), 300, :second)
{:ok, job} =
Pleroma.Workers.ReachabilityWorker.new(
%{"domain" => domain, "phase" => "phase_1min", "attempt" => 1},
scheduled_at: scheduled_at
)
|> Oban.insert()
# Ensure the job is present
job = Pleroma.Repo.get(Oban.Job, job.id)
assert job
# Call set_reachable, which should delete the job
assert {:ok, _} = Instance.set_reachable(domain)
# Reload the job and assert it is deleted
job = Pleroma.Repo.get(Oban.Job, job.id)
refute job
end
end
describe "set_unreachable/1" do
@ -144,7 +167,11 @@ defmodule Pleroma.Instances.InstanceTest do
end
test "Doesn't scrapes unreachable instances" do
instance = insert(:instance, unreachable_since: Instances.reachability_datetime_threshold())
instance =
insert(:instance,
unreachable_since: NaiveDateTime.utc_now() |> NaiveDateTime.add(-:timer.hours(24))
)
url = "https://" <> instance.host
assert capture_log(fn -> assert nil == Instance.get_or_update_favicon(URI.parse(url)) end) =~
@ -212,14 +239,44 @@ defmodule Pleroma.Instances.InstanceTest do
end
end
test "delete_users_and_activities/1 schedules a job to delete the instance and users" do
test "delete/1 schedules a job to delete the instance and users" do
insert(:user, nickname: "mario@mushroom.kingdom", name: "Mario")
{:ok, _job} = Instance.delete_users_and_activities("mushroom.kingdom")
{:ok, _job} = Instance.delete("mushroom.kingdom")
assert_enqueued(
worker: Pleroma.Workers.DeleteWorker,
args: %{"op" => "delete_instance", "host" => "mushroom.kingdom"}
)
end
describe "check_unreachable/1" do
test "schedules a ReachabilityWorker job for the given domain" do
domain = "test.example.com"
# Call check_unreachable
assert {:ok, _job} = Instance.check_unreachable(domain)
# Verify that a ReachabilityWorker job was scheduled
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == domain
end
test "handles multiple calls for the same domain (uniqueness enforced)" do
domain = "duplicate.example.com"
assert {:ok, _job1} = Instance.check_unreachable(domain)
# Second call for the same domain
assert {:ok, %Oban.Job{conflict?: true}} = Instance.check_unreachable(domain)
# Should only have one job due to uniqueness
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == domain
end
end
end

View file

@ -6,74 +6,42 @@ defmodule Pleroma.InstancesTest do
alias Pleroma.Instances
use Pleroma.DataCase
setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
use Oban.Testing, repo: Pleroma.Repo
describe "reachable?/1" do
test "returns `true` for host / url with unknown reachability status" do
assert Instances.reachable?("unknown.site")
assert Instances.reachable?("http://unknown.site")
end
test "returns `false` for host / url marked unreachable for at least `reachability_datetime_threshold()`" do
host = "consistently-unreachable.name"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
refute Instances.reachable?("http://#{host}/path")
end
test "returns `true` for host / url marked unreachable for less than `reachability_datetime_threshold()`" do
url = "http://eventually-unreachable.name/path"
Instances.set_unreachable(url)
assert Instances.reachable?(url)
assert Instances.reachable?(URI.parse(url).host)
end
test "raises FunctionClauseError exception on non-binary input" do
assert_raise FunctionClauseError, fn -> Instances.reachable?(nil) end
assert_raise FunctionClauseError, fn -> Instances.reachable?(1) end
end
end
describe "filter_reachable/1" do
setup do
host = "consistently-unreachable.name"
url1 = "http://eventually-unreachable.com/path"
url2 = "http://domain.com/path"
unreachable_host = "consistently-unreachable.name"
reachable_host = "http://domain.com/path"
Instances.set_consistently_unreachable(host)
Instances.set_unreachable(url1)
Instances.set_unreachable(unreachable_host)
result = Instances.filter_reachable([host, url1, url2, nil])
%{result: result, url1: url1, url2: url2}
result = Instances.filter_reachable([unreachable_host, reachable_host, nil])
%{result: result, reachable_host: reachable_host, unreachable_host: unreachable_host}
end
test "returns a map with keys containing 'not marked consistently unreachable' elements of supplied list",
%{result: result, url1: url1, url2: url2} do
assert is_map(result)
assert Enum.sort([url1, url2]) == result |> Map.keys() |> Enum.sort()
test "returns a list of only reachable elements",
%{result: result, reachable_host: reachable_host} do
assert is_list(result)
assert [reachable_host] == result
end
test "returns a map with `unreachable_since` values for keys",
%{result: result, url1: url1, url2: url2} do
assert is_map(result)
assert %NaiveDateTime{} = result[url1]
assert is_nil(result[url2])
end
test "returns an empty map for empty list or list containing no hosts / url" do
assert %{} == Instances.filter_reachable([])
assert %{} == Instances.filter_reachable([nil])
test "returns an empty list when provided no data" do
assert [] == Instances.filter_reachable([])
assert [] == Instances.filter_reachable([nil])
end
end
describe "set_reachable/1" do
test "sets unreachable url or host reachable" do
host = "domain.com"
Instances.set_consistently_unreachable(host)
Instances.set_unreachable(host)
refute Instances.reachable?(host)
Instances.set_reachable(host)
@ -103,22 +71,68 @@ defmodule Pleroma.InstancesTest do
end
end
describe "set_consistently_unreachable/1" do
test "sets reachable url or host unreachable" do
url = "http://domain.com?q="
assert Instances.reachable?(url)
describe "check_all_unreachable/0" do
test "schedules ReachabilityWorker jobs for all unreachable instances" do
domain1 = "unreachable1.example.com"
domain2 = "unreachable2.example.com"
domain3 = "unreachable3.example.com"
Instances.set_consistently_unreachable(url)
refute Instances.reachable?(url)
Instances.set_unreachable(domain1)
Instances.set_unreachable(domain2)
Instances.set_unreachable(domain3)
Instances.check_all_unreachable()
# Verify that ReachabilityWorker jobs were scheduled for all unreachable domains
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 3
domains = Enum.map(jobs, & &1.args["domain"])
assert domain1 in domains
assert domain2 in domains
assert domain3 in domains
end
test "keeps unreachable url or host unreachable" do
host = "site.name"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
test "does not schedule jobs for reachable instances" do
unreachable_domain = "unreachable.example.com"
reachable_domain = "reachable.example.com"
Instances.set_consistently_unreachable(host)
refute Instances.reachable?(host)
Instances.set_unreachable(unreachable_domain)
Instances.set_reachable(reachable_domain)
Instances.check_all_unreachable()
# Verify that only one job was scheduled (for the unreachable domain)
jobs = all_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args["domain"] == unreachable_domain
end
end
test "delete_all_unreachable/0 schedules DeleteWorker jobs for all unreachable instances" do
domain1 = "unreachable1.example.com"
domain2 = "unreachable2.example.com"
domain3 = "unreachable3.example.com"
Instances.set_unreachable(domain1)
Instances.set_unreachable(domain2)
Instances.set_unreachable(domain3)
Instances.delete_all_unreachable()
# Verify that DeleteWorker jobs were scheduled for all unreachable domains
jobs = all_enqueued(worker: Pleroma.Workers.DeleteWorker)
assert length(jobs) == 3
domains = Enum.map(jobs, & &1.args["host"])
assert domain1 in domains
assert domain2 in domains
assert domain3 in domains
# Verify all jobs are delete_instance operations
Enum.each(jobs, fn job ->
assert job.args["op"] == "delete_instance"
end)
end
end

View file

@ -6,7 +6,6 @@ defmodule Pleroma.Object.FetcherTest do
use Pleroma.DataCase
alias Pleroma.Activity
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Object.Fetcher
alias Pleroma.Web.ActivityPub.ObjectValidator
@ -250,17 +249,6 @@ defmodule Pleroma.Object.FetcherTest do
result = Fetcher.fetch_object_from_id("https://example.com/objects/no-content-type")
assert {:fetch, {:error, nil}} = result
end
test "it resets instance reachability on successful fetch" do
id = "http://mastodon.example.org/@admin/99541947525187367"
Instances.set_consistently_unreachable(id)
refute Instances.reachable?(id)
{:ok, _object} =
Fetcher.fetch_object_from_id("http://mastodon.example.org/@admin/99541947525187367")
assert Instances.reachable?(id)
end
end
describe "implementation quirks" do

View file

@ -8,7 +8,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
alias Pleroma.Activity
alias Pleroma.Delivery
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Tests.ObanHelpers
alias Pleroma.User
@ -601,23 +600,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert Activity.get_by_ap_id(data["id"])
end
test "it clears `unreachable` federation status of the sender", %{conn: conn} do
data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!()
sender_url = data["actor"]
Instances.set_consistently_unreachable(sender_url)
refute Instances.reachable?(sender_url)
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/inbox", data)
assert "ok" == json_response(conn, 200)
assert Instances.reachable?(sender_url)
end
test "accept follow activity", %{conn: conn} do
clear_config([:instance, :federating], true)
relay = Relay.get_actor()
@ -1108,24 +1090,6 @@ defmodule Pleroma.Web.ActivityPub.ActivityPubControllerTest do
assert response(conn, 200) =~ note_object.data["content"]
end
test "it clears `unreachable` federation status of the sender", %{conn: conn, data: data} do
user = insert(:user)
data = Map.put(data, "bcc", [user.ap_id])
sender_host = URI.parse(data["actor"]).host
Instances.set_consistently_unreachable(sender_host)
refute Instances.reachable?(sender_host)
conn =
conn
|> assign(:valid_signature, true)
|> put_req_header("content-type", "application/activity+json")
|> post("/users/#{user.nickname}/inbox", data)
assert "ok" == json_response(conn, 200)
assert Instances.reachable?(sender_host)
end
test "it removes all follower collections but actor's", %{conn: conn} do
[actor, recipient] = insert_pair(:user)

View file

@ -6,13 +6,11 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
use Oban.Testing, repo: Pleroma.Repo
use Pleroma.Web.ConnCase
import ExUnit.CaptureLog
import Pleroma.Factory
import Tesla.Mock
import Mock
alias Pleroma.Activity
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Tests.ObanHelpers
alias Pleroma.Web.ActivityPub.Publisher
@ -167,115 +165,6 @@ defmodule Pleroma.Web.ActivityPub.PublisherTest do
})
|> Publisher.publish_one()
end
test_with_mock "calls `Instances.set_reachable` on successful federation if `unreachable_since` is set",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://200.site/users/nick1/inbox"
activity = insert(:note_activity)
assert {:ok, _} =
Publisher.prepare_one(%{
inbox: inbox,
activity_id: activity.id,
unreachable_since: NaiveDateTime.utc_now() |> NaiveDateTime.to_string()
})
|> Publisher.publish_one()
assert called(Instances.set_reachable(inbox))
end
test_with_mock "does NOT call `Instances.set_reachable` on successful federation if `unreachable_since` is nil",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://200.site/users/nick1/inbox"
activity = insert(:note_activity)
assert {:ok, _} =
Publisher.prepare_one(%{
inbox: inbox,
activity_id: activity.id,
unreachable_since: nil
})
|> Publisher.publish_one()
refute called(Instances.set_reachable(inbox))
end
test_with_mock "calls `Instances.set_unreachable` on target inbox on non-2xx HTTP response code",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://404.site/users/nick1/inbox"
activity = insert(:note_activity)
assert {:cancel, _} =
Publisher.prepare_one(%{inbox: inbox, activity_id: activity.id})
|> Publisher.publish_one()
assert called(Instances.set_unreachable(inbox))
end
test_with_mock "it calls `Instances.set_unreachable` on target inbox on request error of any kind",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://connrefused.site/users/nick1/inbox"
activity = insert(:note_activity)
assert capture_log(fn ->
assert {:error, _} =
Publisher.prepare_one(%{
inbox: inbox,
activity_id: activity.id
})
|> Publisher.publish_one()
end) =~ "connrefused"
assert called(Instances.set_unreachable(inbox))
end
test_with_mock "does NOT call `Instances.set_unreachable` if target is reachable",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://200.site/users/nick1/inbox"
activity = insert(:note_activity)
assert {:ok, _} =
Publisher.prepare_one(%{inbox: inbox, activity_id: activity.id})
|> Publisher.publish_one()
refute called(Instances.set_unreachable(inbox))
end
test_with_mock "does NOT call `Instances.set_unreachable` if target instance has non-nil `unreachable_since`",
Instances,
[:passthrough],
[] do
_actor = insert(:user)
inbox = "http://connrefused.site/users/nick1/inbox"
activity = insert(:note_activity)
assert capture_log(fn ->
assert {:error, _} =
Publisher.prepare_one(%{
inbox: inbox,
activity_id: activity.id,
unreachable_since: NaiveDateTime.utc_now() |> NaiveDateTime.to_string()
})
|> Publisher.publish_one()
end) =~ "connrefused"
refute called(Instances.set_unreachable(inbox))
end
end
describe "publish/2" do

View file

@ -126,22 +126,17 @@ defmodule Pleroma.Web.FederatorTest do
inbox: inbox2
})
dt = NaiveDateTime.utc_now()
Instances.set_unreachable(inbox1, dt)
Instances.set_consistently_unreachable(URI.parse(inbox2).host)
Instances.set_unreachable(URI.parse(inbox2).host)
{:ok, _activity} =
CommonAPI.post(user, %{status: "HI @nick1@domain.com, @nick2@domain2.com!"})
expected_dt = NaiveDateTime.to_iso8601(dt)
ObanHelpers.perform(all_enqueued(worker: PublisherWorker))
assert ObanHelpers.member?(
%{
"op" => "publish_one",
"params" => %{"inbox" => inbox1, "unreachable_since" => expected_dt}
"params" => %{"inbox" => inbox1}
},
all_enqueued(worker: PublisherWorker)
)

View file

@ -7,16 +7,11 @@ defmodule Pleroma.Web.PleromaApi.InstancesControllerTest do
alias Pleroma.Instances
setup_all do: clear_config([:instance, :federation_reachability_timeout_days], 1)
setup do
constant = "http://consistently-unreachable.name/"
eventual = "http://eventually-unreachable.com/path"
{:ok, %Pleroma.Instances.Instance{unreachable_since: constant_unreachable}} =
Instances.set_consistently_unreachable(constant)
_eventual_unreachable = Instances.set_unreachable(eventual)
Instances.set_unreachable(constant)
%{constant_unreachable: constant_unreachable, constant: constant}
end

View file

@ -17,7 +17,7 @@ defmodule Pleroma.Workers.DeleteWorkerTest do
user1 = insert(:user, nickname: "alice@example.com", name: "Alice")
user2 = insert(:user, nickname: "bob@example.com", name: "Bob")
{:ok, job} = Instance.delete_users_and_activities("example.com")
{:ok, job} = Instance.delete("example.com")
assert_enqueued(
worker: DeleteWorker,

View file

@ -7,7 +7,9 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
use Oban.Testing, repo: Pleroma.Repo
import Pleroma.Factory
import Mock
alias Pleroma.Instances
alias Pleroma.Object
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Builder
@ -37,4 +39,85 @@ defmodule Pleroma.Workers.PublisherWorkerTest do
assert {:ok, %Oban.Job{priority: 0}} = Federator.publish(post)
end
end
describe "Server reachability:" do
setup do
user = insert(:user)
remote_user = insert(:user, local: false, inbox: "https://example.com/inbox")
{:ok, _, _} = Pleroma.User.follow(remote_user, user)
{:ok, activity} = CommonAPI.post(user, %{status: "Test post"})
%{
user: user,
remote_user: remote_user,
activity: activity
}
end
test "marks server as unreachable only on final failure", %{activity: activity} do
with_mock Pleroma.Web.Federator,
perform: fn :publish_one, _params -> {:error, :connection_error} end do
# First attempt
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job)
assert Instances.reachable?("https://example.com/inbox")
# Final attempt
job = %{job | attempt: 5}
assert {:error, :connection_error} = Pleroma.Workers.PublisherWorker.perform(job)
refute Instances.reachable?("https://example.com/inbox")
end
end
test "does not mark server as unreachable on successful publish", %{activity: activity} do
with_mock Pleroma.Web.Federator,
perform: fn :publish_one, _params -> {:ok, %{status: 200}} end do
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert :ok = Pleroma.Workers.PublisherWorker.perform(job)
assert Instances.reachable?("https://example.com/inbox")
end
end
test "cancels job if server is unreachable", %{activity: activity} do
# First mark the server as unreachable
Instances.set_unreachable("https://example.com/inbox")
refute Instances.reachable?("https://example.com/inbox")
job = %Oban.Job{
args: %{
"op" => "publish_one",
"params" => %{
"inbox" => "https://example.com/inbox",
"activity_id" => activity.id
}
},
attempt: 1,
max_attempts: 5
}
assert {:cancel, :unreachable} = Pleroma.Workers.PublisherWorker.perform(job)
end
end
end

View file

@ -0,0 +1,226 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.ReachabilityWorkerTest do
use Pleroma.DataCase, async: true
use Oban.Testing, repo: Pleroma.Repo
import Mock
alias Pleroma.Tests.ObanHelpers
alias Pleroma.Workers.ReachabilityWorker
setup do
ObanHelpers.wipe_all()
:ok
end
describe "progressive backoff phases" do
test "starts with phase_1min and progresses through phases on failure" do
domain = "example.com"
with_mocks([
{Pleroma.HTTP, [], [get: fn _ -> {:error, :timeout} end]},
{Pleroma.Instances, [], [set_reachable: fn _ -> :ok end]}
]) do
# Start with phase_1min
job = %Oban.Job{
args: %{"domain" => domain, "phase" => "phase_1min", "attempt" => 1}
}
# First attempt fails
assert {:error, :timeout} = ReachabilityWorker.perform(job)
# Should schedule retry for phase_1min (attempt 2)
retry_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(retry_jobs) == 1
[retry_job] = retry_jobs
assert retry_job.args["phase"] == "phase_1min"
assert retry_job.args["attempt"] == 2
# Clear jobs and simulate second attempt failure
ObanHelpers.wipe_all()
retry_job = %Oban.Job{
args: %{"domain" => domain, "phase" => "phase_1min", "attempt" => 2}
}
assert {:error, :timeout} = ReachabilityWorker.perform(retry_job)
# Should schedule retry for phase_1min (attempt 3)
retry_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(retry_jobs) == 1
[retry_job] = retry_jobs
assert retry_job.args["phase"] == "phase_1min"
assert retry_job.args["attempt"] == 3
# Clear jobs and simulate third attempt failure (final attempt for phase_1min)
ObanHelpers.wipe_all()
retry_job = %Oban.Job{
args: %{"domain" => domain, "phase" => "phase_1min", "attempt" => 3}
}
assert {:error, :timeout} = ReachabilityWorker.perform(retry_job)
# Should schedule retry for phase_1min (attempt 4)
retry_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(retry_jobs) == 1
[retry_job] = retry_jobs
assert retry_job.args["phase"] == "phase_1min"
assert retry_job.args["attempt"] == 4
# Clear jobs and simulate fourth attempt failure (final attempt for phase_1min)
ObanHelpers.wipe_all()
retry_job = %Oban.Job{
args: %{"domain" => domain, "phase" => "phase_1min", "attempt" => 4}
}
assert {:error, :timeout} = ReachabilityWorker.perform(retry_job)
# Should schedule next phase (phase_15min)
next_phase_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(next_phase_jobs) == 1
[next_phase_job] = next_phase_jobs
assert next_phase_job.args["phase"] == "phase_15min"
assert next_phase_job.args["attempt"] == 1
end
end
test "progresses through all phases correctly" do
domain = "example.com"
with_mocks([
{Pleroma.HTTP, [], [get: fn _ -> {:error, :timeout} end]},
{Pleroma.Instances, [], [set_reachable: fn _ -> :ok end]}
]) do
# Simulate all phases failing
phases = ["phase_1min", "phase_15min", "phase_1hour", "phase_8hour", "phase_24hour"]
Enum.each(phases, fn phase ->
{_interval, max_attempts, next_phase} = get_phase_config(phase)
# Simulate all attempts failing for this phase
Enum.each(1..max_attempts, fn attempt ->
job = %Oban.Job{args: %{"domain" => domain, "phase" => phase, "attempt" => attempt}}
assert {:error, :timeout} = ReachabilityWorker.perform(job)
if attempt < max_attempts do
# Should schedule retry for same phase
retry_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(retry_jobs) == 1
[retry_job] = retry_jobs
assert retry_job.args["phase"] == phase
assert retry_job.args["attempt"] == attempt + 1
ObanHelpers.wipe_all()
else
# Should schedule next phase (except for final phase)
if next_phase != "final" do
next_phase_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(next_phase_jobs) == 1
[next_phase_job] = next_phase_jobs
assert next_phase_job.args["phase"] == next_phase
assert next_phase_job.args["attempt"] == 1
ObanHelpers.wipe_all()
else
# Final phase - no more jobs should be scheduled
next_phase_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(next_phase_jobs) == 0
end
end
end)
end)
end
end
test "succeeds and stops progression when instance becomes reachable" do
domain = "example.com"
with_mocks([
{Pleroma.HTTP, [], [get: fn _ -> {:ok, %{status: 200}} end]},
{Pleroma.Instances, [], [set_reachable: fn _ -> :ok end]}
]) do
job = %Oban.Job{args: %{"domain" => domain, "phase" => "phase_1hour", "attempt" => 2}}
# Should succeed and not schedule any more jobs
assert :ok = ReachabilityWorker.perform(job)
# Verify set_reachable was called
assert_called(Pleroma.Instances.set_reachable("https://#{domain}"))
# No more jobs should be scheduled
next_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(next_jobs) == 0
end
end
test "enforces uniqueness per domain using Oban's conflict detection" do
domain = "example.com"
# Insert first job for the domain
job1 =
%{
"domain" => domain,
"phase" => "phase_1min",
"attempt" => 1
}
|> ReachabilityWorker.new()
|> Oban.insert()
assert {:ok, _} = job1
# Try to insert a second job for the same domain with different phase/attempt
job2 =
%{
"domain" => domain,
"phase" => "phase_15min",
"attempt" => 1
}
|> ReachabilityWorker.new()
|> Oban.insert()
# Should fail due to uniqueness constraint (conflict)
assert {:ok, %Oban.Job{conflict?: true}} = job2
# Verify only one job exists for this domain
jobs = all_enqueued(worker: ReachabilityWorker)
assert length(jobs) == 1
[existing_job] = jobs
assert existing_job.args["domain"] == domain
assert existing_job.args["phase"] == "phase_1min"
end
test "handles new jobs with only domain argument and transitions them to the first phase" do
domain = "legacy.example.com"
with_mocks([
{Pleroma.Instances, [], [set_reachable: fn _ -> :ok end]}
]) do
# Create a job with only domain (legacy format)
job = %Oban.Job{
args: %{"domain" => domain}
}
# Should reschedule with phase_1min and attempt 1
assert :ok = ReachabilityWorker.perform(job)
# Check that a new job was scheduled with the correct format
scheduled_jobs = all_enqueued(worker: ReachabilityWorker)
assert length(scheduled_jobs) == 1
[scheduled_job] = scheduled_jobs
assert scheduled_job.args["domain"] == domain
assert scheduled_job.args["phase"] == "phase_1min"
assert scheduled_job.args["attempt"] == 1
end
end
end
defp get_phase_config("phase_1min"), do: {1, 4, "phase_15min"}
defp get_phase_config("phase_15min"), do: {15, 4, "phase_1hour"}
defp get_phase_config("phase_1hour"), do: {60, 4, "phase_8hour"}
defp get_phase_config("phase_8hour"), do: {480, 4, "phase_24hour"}
defp get_phase_config("phase_24hour"), do: {1440, 4, "final"}
defp get_phase_config("final"), do: {nil, 0, nil}
end

View file

@ -3,13 +3,14 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.ReceiverWorkerTest do
use Pleroma.DataCase
use Pleroma.DataCase, async: true
use Oban.Testing, repo: Pleroma.Repo
import Mock
import Pleroma.Factory
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Federator
alias Pleroma.Workers.ReceiverWorker
@ -243,4 +244,62 @@ defmodule Pleroma.Workers.ReceiverWorkerTest do
assert {:cancel, _} = ReceiverWorker.perform(oban_job)
end
describe "Server reachability:" do
setup do
user = insert(:user)
remote_user = insert(:user, local: false, ap_id: "https://example.com/users/remote")
{:ok, _, _} = Pleroma.User.follow(user, remote_user)
{:ok, activity} = CommonAPI.post(remote_user, %{status: "Test post"})
%{
user: user,
remote_user: remote_user,
activity: activity
}
end
test "schedules ReachabilityWorker if host is unreachable", %{activity: activity} do
with_mocks [
{Pleroma.Web.ActivityPub.Transmogrifier, [],
[handle_incoming: fn _ -> {:ok, activity} end]},
{Pleroma.Instances, [], [reachable?: fn _ -> false end]},
{Pleroma.Web.Federator, [], [perform: fn :incoming_ap_doc, _params -> {:ok, nil} end]}
] do
job = %Oban.Job{
args: %{
"op" => "incoming_ap_doc",
"params" => activity.data
}
}
Pleroma.Workers.ReceiverWorker.perform(job)
assert_enqueued(
worker: Pleroma.Workers.ReachabilityWorker,
args: %{"domain" => "example.com"}
)
end
end
test "does not schedule ReachabilityWorker if host is reachable", %{activity: activity} do
with_mocks [
{Pleroma.Web.ActivityPub.Transmogrifier, [],
[handle_incoming: fn _ -> {:ok, activity} end]},
{Pleroma.Instances, [], [reachable?: fn _ -> true end]},
{Pleroma.Web.Federator, [], [perform: fn :incoming_ap_doc, _params -> {:ok, nil} end]}
] do
job = %Oban.Job{
args: %{
"op" => "incoming_ap_doc",
"params" => activity.data
}
}
Pleroma.Workers.ReceiverWorker.perform(job)
refute_enqueued(worker: Pleroma.Workers.ReachabilityWorker)
end
end
end
end

View file

@ -3,7 +3,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.RemoteFetcherWorkerTest do
use Pleroma.DataCase
use Pleroma.DataCase, async: true
use Oban.Testing, repo: Pleroma.Repo
alias Pleroma.Workers.RemoteFetcherWorker