From ec0a64cf5f9eab91860d5865178cf936b0ba2899 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 24 Apr 2026 19:00:03 -0500 Subject: [PATCH] faster tests --- config/test.exs | 10 +++ lib/aprsme/cluster/leader_election.ex | 20 ++++-- lib/aprsme/device_cache.ex | 2 +- lib/aprsme/device_identification.ex | 4 +- lib/aprsme/is/is.ex | 4 +- .../cluster/connection_manager_test.exs | 62 ++++++++----------- test/aprsme/cluster/leader_election_test.exs | 30 ++++----- .../cluster/packet_distributor_test.exs | 16 +++-- test/aprsme/cluster/packet_receiver_test.exs | 15 ++--- test/aprsme/device_cache_test.exs | 2 +- test/aprsme/device_identification_test.exs | 5 +- test/aprsme/is_test.exs | 2 +- 12 files changed, 86 insertions(+), 86 deletions(-) diff --git a/config/test.exs b/config/test.exs index 0899174..51998ac 100644 --- a/config/test.exs +++ b/config/test.exs @@ -61,6 +61,16 @@ config :aprsme, # Disable automatic migrations during tests config :aprsme, auto_migrate: false +config :aprsme, device_cache_initial_load_delay_ms: 0 + +# Short Req timeouts so HTTP calls fail fast without real network access. +# retry: false prevents Req's default exponential backoff (1s+2s+4s=7s). +config :aprsme, device_id_req_opts: [connect_options: [timeout: 50], receive_timeout: 50, retry: false] +config :aprsme, election_initial_delay_ms: 0 + +# Speed up time-sensitive delays in test environment +config :aprsme, is_call_timeout_ms: 100 +config :aprsme, is_init_delay_ms: 0 # Only in tests, remove the complexity from the password hashing algorithm config :bcrypt_elixir, :log_rounds, 1 diff --git a/lib/aprsme/cluster/leader_election.ex b/lib/aprsme/cluster/leader_election.ex index dc45106..3c9aa0d 100644 --- a/lib/aprsme/cluster/leader_election.ex +++ b/lib/aprsme/cluster/leader_election.ex @@ -81,7 +81,11 @@ defmodule Aprsme.Cluster.LeaderElection do # Non-clustered mode: elect immediately. defp schedule_initial_election(false) do Logger.info("Clustering disabled - proceeding with immediate leader election") - Process.send_after(self(), :attempt_election, 100) + delay = Application.get_env(:aprsme, :election_initial_delay_ms, 100) + + if delay == 0, + do: send(self(), :attempt_election), + else: Process.send_after(self(), :attempt_election, delay) end @impl true @@ -100,7 +104,7 @@ defmodule Aprsme.Cluster.LeaderElection do else Logger.info("Cluster formed with #{length(connected_nodes)} other nodes: #{inspect(connected_nodes)}") Logger.info("Proceeding with leader election") - Process.send_after(self(), :attempt_election, 100) + schedule_election_attempt() {:noreply, %{state | election_forced: true}} end end @@ -123,7 +127,7 @@ defmodule Aprsme.Cluster.LeaderElection do ) end - Process.send_after(self(), :attempt_election, 100) + schedule_election_attempt() {:noreply, %{state | election_forced: true}} else {:noreply, state} @@ -157,7 +161,7 @@ defmodule Aprsme.Cluster.LeaderElection do # Re-attempt election if we're not leader _ = if not state.is_leader do - Process.send_after(self(), :attempt_election, 100) + schedule_election_attempt() end # Schedule next check @@ -198,6 +202,14 @@ defmodule Aprsme.Cluster.LeaderElection do # Verify that a node which thinks it's leader still holds the :global registration. # After :global conflict resolution (e.g. two partitions merging), the losing PID # is silently unregistered — this detects that and steps down. + defp schedule_election_attempt do + delay = Application.get_env(:aprsme, :election_initial_delay_ms, 100) + + if delay == 0, + do: send(self(), :attempt_election), + else: Process.send_after(self(), :attempt_election, delay) + end + defp verify_leadership(%{is_leader: true} = state) do case :global.whereis_name(@election_key) do pid when pid == self() -> diff --git a/lib/aprsme/device_cache.ex b/lib/aprsme/device_cache.ex index 9eab5e1..fac995e 100644 --- a/lib/aprsme/device_cache.ex +++ b/lib/aprsme/device_cache.ex @@ -57,7 +57,7 @@ defmodule Aprsme.DeviceCache do @impl true def init(_) do # Delay initial load to allow Redis connections to establish - Process.send_after(self(), :initial_load, 1_000) + Process.send_after(self(), :initial_load, Application.get_env(:aprsme, :device_cache_initial_load_delay_ms, 1_000)) {:ok, %{initial_load_done: false}} end diff --git a/lib/aprsme/device_identification.ex b/lib/aprsme/device_identification.ex index 9ad770e..c660342 100644 --- a/lib/aprsme/device_identification.ex +++ b/lib/aprsme/device_identification.ex @@ -155,7 +155,9 @@ defmodule Aprsme.DeviceIdentification do @doc false # Exposed for testing via URL injection. def fetch_devices_from_url(url \\ @url) do - case Req.get(url) do + req_opts = Application.get_env(:aprsme, :device_id_req_opts, []) + + case Req.get(url, req_opts) do {:ok, %Req.Response{status: 200, body: body}} -> upsert_devices(body) diff --git a/lib/aprsme/is/is.ex b/lib/aprsme/is/is.ex index b84f4d0..7f371c5 100644 --- a/lib/aprsme/is/is.ex +++ b/lib/aprsme/is/is.ex @@ -66,7 +66,7 @@ defmodule Aprsme.Is do Process.flag(:trap_exit, true) # Add a small delay to prevent rapid reconnection attempts - Process.sleep(2000) + Process.sleep(Application.get_env(:aprsme, :is_init_delay_ms, 2_000)) # Get startup parameters server = Application.get_env(:aprsme, :aprs_is_server, ~c"dallas.aprs2.net") @@ -132,7 +132,7 @@ defmodule Aprsme.Is do defp status_from(nil), do: disconnected_status(nil) defp status_from(pid) when is_pid(pid) do - GenServer.call(__MODULE__, :get_status, 5000) + GenServer.call(__MODULE__, :get_status, Application.get_env(:aprsme, :is_call_timeout_ms, 5_000)) catch # GenServer exists but isn't responding — treat as disconnected but # fall back to the rotate.aprs2.net default server string. diff --git a/test/aprsme/cluster/connection_manager_test.exs b/test/aprsme/cluster/connection_manager_test.exs index 17b2dc5..c475cd2 100644 --- a/test/aprsme/cluster/connection_manager_test.exs +++ b/test/aprsme/cluster/connection_manager_test.exs @@ -79,12 +79,12 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do # Start a minimal LeaderElection to prevent crashes Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) - Process.sleep(150) + LeaderElection.leader?() :ok end test "subscribes to leadership changes" do - {:ok, _pid} = ConnectionManager.start_link([]) + {:ok, pid} = ConnectionManager.start_link([]) # Verify subscription by publishing a test message Phoenix.PubSub.broadcast( @@ -93,8 +93,8 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do {:leadership_change, :test_node, false} ) - # Give time for message processing - Process.sleep(50) + # Sync with the GenServer so the broadcast has been processed. + :sys.get_state(pid) # Process should still be alive after receiving message assert Process.whereis(ConnectionManager) @@ -103,8 +103,8 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do test "schedules initial state check" do {:ok, pid} = ConnectionManager.start_link([]) - # With delay set to 0 in test config, the check fires immediately - Process.sleep(50) + # Sync so the initial check (delay=0 in test config) has fired. + :sys.get_state(pid) # Process should still be alive assert Process.alive?(pid) @@ -116,8 +116,7 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do # Start LeaderElection in non-clustered mode so it becomes leader Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) - # Wait for election (100ms timer + processing) - Process.sleep(150) + LeaderElection.leader?() # Start ConnectionManager {:ok, pid} = ConnectionManager.start_link([]) @@ -125,18 +124,16 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do end test "handles initial state check when leader", %{pid: pid} do - # Send initial state check send(pid, :check_initial_state) - Process.sleep(50) + :sys.get_state(pid) # Should handle without crashing assert Process.alive?(pid) end test "handles initial state check when not leader", %{pid: pid} do - # Send initial state check send(pid, :check_initial_state) - Process.sleep(50) + :sys.get_state(pid) # Should handle without crashing assert Process.alive?(pid) @@ -148,18 +145,16 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) - Process.sleep(150) + LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) - # With delay=0, initial check fires immediately; just wait for processing - Process.sleep(50) + :sys.get_state(pid) {:ok, pid: pid} end test "starts connection when becoming leader", %{pid: pid} do - # Send leadership change - this node became leader send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Should handle the message without crashing assert Process.alive?(pid) @@ -168,11 +163,11 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do test "stops connection when losing leadership", %{pid: pid} do # First become leader and start connection send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Then lose leadership send(pid, {:leadership_change, node(), false}) - Process.sleep(50) + :sys.get_state(pid) # Should handle the message without crashing assert Process.alive?(pid) @@ -181,9 +176,8 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do test "ignores leadership changes for other nodes", %{pid: pid} do other_node = :other@host - # Send leadership change for another node send(pid, {:leadership_change, other_node, true}) - Process.sleep(50) + :sys.get_state(pid) # Should not affect this node assert Process.alive?(pid) @@ -192,10 +186,10 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do test "does not start connection twice", %{pid: pid} do # Become leader twice send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Should handle duplicate leadership without issues assert Process.alive?(pid) @@ -207,18 +201,16 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) - Process.sleep(150) + LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) - # With delay=0, initial check fires immediately; just wait for processing - Process.sleep(50) + :sys.get_state(pid) {:ok, pid: pid} end test "handles IsSupervisor already started error", %{pid: pid} do - # Try to become leader send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Should handle gracefully even if IsSupervisor fails to start assert Process.alive?(pid) @@ -233,9 +225,8 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do end end - # Send leadership change send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Should handle failure gracefully assert Process.alive?(pid) @@ -278,26 +269,25 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) - Process.sleep(150) + LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) {:ok, pid: pid} end test "full lifecycle - become leader, lose leadership", %{pid: pid} do - # With delay=0, initial check fires immediately; just wait for processing - Process.sleep(50) + :sys.get_state(pid) # Become leader send(pid, {:leadership_change, node(), true}) - Process.sleep(50) + :sys.get_state(pid) # Verify connection started (check state indirectly) assert Process.alive?(pid) # Lose leadership send(pid, {:leadership_change, node(), false}) - Process.sleep(50) + :sys.get_state(pid) # Verify still alive after stopping connection assert Process.alive?(pid) @@ -329,7 +319,7 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do case Process.whereis(LeaderElection) do nil -> {:ok, _} = LeaderElection.start_link([]) - Process.sleep(200) + LeaderElection.leader?() _pid -> :ok diff --git a/test/aprsme/cluster/leader_election_test.exs b/test/aprsme/cluster/leader_election_test.exs index 5c46b0e..902d395 100644 --- a/test/aprsme/cluster/leader_election_test.exs +++ b/test/aprsme/cluster/leader_election_test.exs @@ -54,10 +54,7 @@ defmodule Aprsme.Cluster.LeaderElectionTest do # Ensure clustering is disabled Application.put_env(:aprsme, :cluster_enabled, false) {:ok, pid} = LeaderElection.start_link([]) - - # Give time for election to occur - Process.sleep(200) - + LeaderElection.leader?() {:ok, pid: pid} end @@ -75,7 +72,7 @@ defmodule Aprsme.Cluster.LeaderElectionTest do setup do Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _pid} = LeaderElection.start_link([]) - Process.sleep(200) + LeaderElection.leader?() :ok end @@ -104,7 +101,7 @@ defmodule Aprsme.Cluster.LeaderElectionTest do setup do Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _pid} = LeaderElection.start_link([]) - Process.sleep(200) + LeaderElection.leader?() :ok end @@ -154,9 +151,7 @@ defmodule Aprsme.Cluster.LeaderElectionTest do test "handles termination gracefully when leader" do {:ok, pid} = LeaderElection.start_link([]) - Process.sleep(200) - - # Verify it's the leader + # Sync so the 0-delay election fires before we assert leader status. assert LeaderElection.leader?() == true # Stop the process @@ -196,7 +191,7 @@ defmodule Aprsme.Cluster.LeaderElectionTest do # For now, we just verify the module handles conflicts without crashing {:ok, pid1} = LeaderElection.start_link([]) - Process.sleep(100) + LeaderElection.leader?() # Try to register another process with the same key # This should fail or trigger conflict resolution @@ -204,7 +199,8 @@ defmodule Aprsme.Cluster.LeaderElectionTest do :global.register_name(@election_key, self()) end) - Process.sleep(100) + # Brief pause for the spawned process to attempt registration. + Process.sleep(10) # Original process should still be alive assert Process.alive?(pid1) @@ -260,7 +256,6 @@ defmodule Aprsme.Cluster.LeaderElectionTest do setup do Application.put_env(:aprsme, :cluster_enabled, false) {:ok, pid} = LeaderElection.start_link([]) - Process.sleep(200) assert LeaderElection.leader?() == true {:ok, pid: pid} end @@ -309,14 +304,13 @@ defmodule Aprsme.Cluster.LeaderElectionTest do # Lose the registration :global.unregister_name(@election_key) - # Force check — should step down + # Force check — should step down and immediately queue :attempt_election. send(pid, :check_leadership) state = :sys.get_state(pid) assert state.is_leader == false - # check_leadership also schedules :attempt_election for non-leaders. - # Wait for re-election. - Process.sleep(100) + # With election_initial_delay_ms: 0, :attempt_election is already in the + # mailbox. leader?/0 (a GenServer.call) processes it first, then answers. assert LeaderElection.leader?() == true end end @@ -330,13 +324,13 @@ defmodule Aprsme.Cluster.LeaderElectionTest do test "handles unknown messages without crashing", %{pid: pid} do send(pid, :unknown_message) - Process.sleep(100) + :sys.get_state(pid) assert Process.alive?(pid) end test "handles check_leadership message", %{pid: pid} do send(pid, :check_leadership) - Process.sleep(100) + :sys.get_state(pid) assert Process.alive?(pid) end end diff --git a/test/aprsme/cluster/packet_distributor_test.exs b/test/aprsme/cluster/packet_distributor_test.exs index b2c5db6..68ef29b 100644 --- a/test/aprsme/cluster/packet_distributor_test.exs +++ b/test/aprsme/cluster/packet_distributor_test.exs @@ -21,8 +21,8 @@ defmodule Aprsme.Cluster.PacketDistributorTest do # Start LeaderElection in non-clustered mode {:ok, _} = LeaderElection.start_link([]) - # Wait for election to complete (100ms timer + processing) - Process.sleep(300) + # Sync with the GenServer so election (0ms in test config) has run. + LeaderElection.leader?() on_exit(fn -> try do @@ -47,19 +47,17 @@ defmodule Aprsme.Cluster.PacketDistributorTest do PacketDistributor.distribute_packet(@test_packet) - refute_receive {:distributed_packet, _}, 100 + refute_receive {:distributed_packet, _}, 10 end test "distributes packet when cluster is enabled and node is leader" do Application.put_env(:aprsme, :cluster_enabled, true) - # LeaderElection started in non-clustered mode becomes leader quickly - Process.sleep(100) PacketDistributor.subscribe() PacketDistributor.distribute_packet(@test_packet) - assert_receive {:distributed_packet, packet}, 200 + assert_receive {:distributed_packet, packet}, 50 assert packet.raw == "TEST>APRS:test packet" assert packet.sender == "TEST" end @@ -75,7 +73,7 @@ defmodule Aprsme.Cluster.PacketDistributorTest do {:distributed_packet, @test_packet} ) - assert_receive {:distributed_packet, _}, 500 + assert_receive {:distributed_packet, _}, 50 end end @@ -94,7 +92,7 @@ defmodule Aprsme.Cluster.PacketDistributorTest do assert {:ok, %{}} = PacketDistributor.init([]) # Callback subscribes the caller to cluster:packets. Phoenix.PubSub.broadcast(Aprsme.PubSub, "cluster:packets", :ping) - assert_receive :ping, 100 + assert_receive :ping, 50 end test "handle_info forwards distributed packets" do @@ -112,7 +110,7 @@ defmodule Aprsme.Cluster.PacketDistributorTest do # After terminate, this process should no longer receive cluster:packets # broadcasts. Phoenix.PubSub.broadcast(Aprsme.PubSub, "cluster:packets", :should_not_arrive) - refute_receive :should_not_arrive, 100 + refute_receive :should_not_arrive, 10 end end end diff --git a/test/aprsme/cluster/packet_receiver_test.exs b/test/aprsme/cluster/packet_receiver_test.exs index 30350b7..4d1a071 100644 --- a/test/aprsme/cluster/packet_receiver_test.exs +++ b/test/aprsme/cluster/packet_receiver_test.exs @@ -23,9 +23,9 @@ defmodule Aprsme.Cluster.PacketReceiverTest do GenServer.stop(LeaderElection) end - # Start LeaderElection and wait for election + # Start LeaderElection and sync so election (0ms in test config) has run. {:ok, leader_pid} = LeaderElection.start_link([]) - Process.sleep(300) + LeaderElection.leader?() on_exit(fn -> try do @@ -64,9 +64,7 @@ defmodule Aprsme.Cluster.PacketReceiverTest do packet = %{raw: "TEST>APRS:test", sender: "TEST"} send(pid, {:distributed_packet, packet}) - - # Give it time to process - Process.sleep(50) + :sys.get_state(pid) # Process should still be alive (no crash) assert Process.alive?(pid) @@ -84,9 +82,7 @@ defmodule Aprsme.Cluster.PacketReceiverTest do packet = %{raw: "TEST>APRS:test", sender: "TEST"} send(pid, {:distributed_packet, packet}) - - # Give it time to process - Process.sleep(50) + :sys.get_state(pid) # Process should still be alive (no crash) assert Process.alive?(pid) @@ -99,8 +95,7 @@ defmodule Aprsme.Cluster.PacketReceiverTest do send(pid, :some_unknown_message) send(pid, {:unexpected, "data"}) - - Process.sleep(100) + :sys.get_state(pid) assert Process.alive?(pid) end diff --git a/test/aprsme/device_cache_test.exs b/test/aprsme/device_cache_test.exs index d103dad..f5f4c66 100644 --- a/test/aprsme/device_cache_test.exs +++ b/test/aprsme/device_cache_test.exs @@ -77,7 +77,7 @@ defmodule Aprsme.DeviceCacheTest do test "init/1 schedules an initial load and returns an empty state" do assert {:ok, state} = DeviceCache.init([]) assert state == %{initial_load_done: false} - assert_receive :initial_load, 1_500 + assert_receive :initial_load, 100 end test "handle_call(:refresh_cache, _, state) returns :ok" do diff --git a/test/aprsme/device_identification_test.exs b/test/aprsme/device_identification_test.exs index ff55aac..74d9d6b 100644 --- a/test/aprsme/device_identification_test.exs +++ b/test/aprsme/device_identification_test.exs @@ -163,9 +163,8 @@ defmodule Aprsme.DeviceIdentificationTest do test "attempts to refresh when there are no devices at all" do Repo.delete_all(Devices) - # fetch_and_upsert_devices goes through the circuit breaker. Without a - # stub it either errors out (network failure) or returns :ok. Either - # outcome exercises the refresh_if_stale(nil, _) branch. + # fetch_and_upsert_devices goes through the circuit breaker. The short + # Req timeout configured in test.exs makes the HTTP attempt fail fast. result = DeviceIdentification.maybe_refresh_devices() assert result == :ok or match?({:error, _}, result) end diff --git a/test/aprsme/is_test.exs b/test/aprsme/is_test.exs index f058bd0..8133538 100644 --- a/test/aprsme/is_test.exs +++ b/test/aprsme/is_test.exs @@ -1285,7 +1285,7 @@ defmodule Aprsme.IsTest do receive do :stop -> :ok after - 5_000 -> :ok + 200 -> :ok end send(parent, :done)