aprs.me/test/aprsme/cluster/leader_election_test.exs
Graham McIntire b86153cd27
Fix all mix credo --strict warnings (188 → 0)
- Replace apply/2 with direct fully-qualified calls in movement_test
- Fix assert_receive timeouts < 1000ms across 8 test files
- Move nested import statements to module-level scope
- Fix tests with no assertions and add missing doctest
- Replace weak type assertions with specific value checks
- Fix conditional assertions and length/1 expensive patterns
- Disable inappropriate Jump.CredoChecks.AvoidSocketAssignsInTest
- Fix tests not calling application code with credo:disable
- Add various credo:disable comments for legitimate patterns
2026-06-12 16:27:20 -05:00

716 lines
23 KiB
Elixir

defmodule Aprsme.Cluster.LeaderElectionTest do
use ExUnit.Case, async: false
alias Aprsme.Cluster.LeaderElection
@election_key {:aprs_is_leader, LeaderElection}
setup do
# Clean up any existing global registrations
:global.unregister_name(@election_key)
# Stop any running LeaderElection process
if Process.whereis(LeaderElection) do
GenServer.stop(LeaderElection)
end
on_exit(fn ->
# Cleanup after test
:global.unregister_name(@election_key)
if Process.whereis(LeaderElection) do
try do
GenServer.stop(LeaderElection)
catch
:exit, _ -> :ok
end
end
end)
:ok
end
describe "start_link/1" do
test "starts the GenServer process" do
assert {:ok, pid} = LeaderElection.start_link([])
assert Process.alive?(pid)
assert Process.whereis(LeaderElection) == pid
# Clean up after this specific test
GenServer.stop(pid)
end
test "registers with the given name" do
{:ok, pid} = LeaderElection.start_link([])
assert Process.whereis(LeaderElection)
# Clean up after this specific test
GenServer.stop(pid)
end
end
describe "leader election in non-clustered mode" do
setup do
# Ensure clustering is disabled
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, pid} = LeaderElection.start_link([])
LeaderElection.leader?()
{:ok, pid: pid}
end
test "elects itself as leader when clustering is disabled", %{pid: _pid} do
assert LeaderElection.leader?() == true
assert LeaderElection.current_leader() == node()
end
test "registers itself globally", %{pid: pid} do
assert LeaderElection.current_leader() == node()
assert :global.whereis_name(@election_key) == pid
end
end
describe "leader?/0" do
setup do
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, _pid} = LeaderElection.start_link([])
LeaderElection.leader?()
:ok
end
test "returns leadership status" do
assert LeaderElection.leader?() in [true, false]
end
test "cached check matches GenServer state" do
# leader_cached?/0 reads from :persistent_term — no GenServer.call
assert LeaderElection.leader_cached?() == LeaderElection.leader?()
end
test "cached check returns false before election" do
# Stop current instance
GenServer.stop(LeaderElection)
:global.unregister_name({:aprs_is_leader, LeaderElection})
# Clear the persistent_term
:persistent_term.put({LeaderElection, :is_leader}, false)
assert LeaderElection.leader_cached?() == false
end
end
describe "current_leader/0" do
setup do
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, _pid} = LeaderElection.start_link([])
LeaderElection.leader?()
:ok
end
test "returns the current leader node" do
leader = LeaderElection.current_leader()
case leader do
nil -> :ok
_ -> assert leader == node()
end
end
end
describe "get_cluster_aprs_status/0" do
test "returns local status when clustering is disabled" do
Application.put_env(:aprsme, :cluster_enabled, false)
# Since Aprsme.Is.get_status() is called directly, we need to ensure
# the module exists or mock it at a lower level
# For now, just verify the function doesn't crash
result = LeaderElection.get_cluster_aprs_status()
case result do
nil -> :ok
%{} -> :ok
end
end
test "returns cluster-wide status when clustering is enabled" do
Application.put_env(:aprsme, :cluster_enabled, true)
# This will call get_cluster_wide_status which handles the cluster logic
status = LeaderElection.get_cluster_aprs_status()
# The function should return a map with cluster_info when in cluster mode
case status do
nil -> :ok
%{} -> :ok
end
# If we got a valid status back, it should have cluster info
if is_map(status) do
assert Map.has_key?(status, :cluster_info)
if status.cluster_info do
assert status.cluster_info.total_nodes >= 1
assert status.cluster_info.all_nodes != []
end
end
end
end
describe "leadership transitions" do
setup do
Application.put_env(:aprsme, :cluster_enabled, false)
:ok
end
test "handles termination gracefully when leader" do
{:ok, pid} = LeaderElection.start_link([])
# Sync so the 0-delay election fires before we assert leader status.
assert LeaderElection.leader?() == true
# Stop the process
GenServer.stop(pid)
# Verify global registration is cleaned up
assert :global.whereis_name(@election_key) == :undefined
end
test "periodic leadership check continues running" do
Application.put_env(:aprsme, :election_check_interval_ms, 50)
on_exit(fn -> Application.delete_env(:aprsme, :election_check_interval_ms) end)
{:ok, _pid} = LeaderElection.start_link([])
# Initial state - might not be leader yet
_initial_leader = LeaderElection.leader?()
# Wait for periodic check (50ms interval, plus buffer)
Process.sleep(150)
# Should still be able to query leadership
current_leader = LeaderElection.leader?()
assert current_leader in [true, false]
# In non-clustered mode, should become leader
if not Application.get_env(:aprsme, :cluster_enabled, false) do
assert current_leader == true
end
end
end
describe "conflict resolution" do
test "resolve_conflict prefers lexicographically lower node" do
# We can't directly test the private function, but we can test the behavior
# by starting multiple processes and seeing which wins
# This is more of an integration test that would require multiple nodes
# For now, we just verify the module handles conflicts without crashing
{:ok, pid1} = LeaderElection.start_link([])
LeaderElection.leader?()
# Try to register another process with the same key
# This should fail or trigger conflict resolution
spawn(fn ->
:global.register_name(@election_key, self())
end)
# Brief pause for the spawned process to attempt registration.
Process.sleep(10)
# Original process should still be alive
assert Process.alive?(pid1)
end
end
describe "stale registration cleanup" do
test "cleans up registration when process dies" do
# Register a dead process
dead_pid = spawn(fn -> :ok end)
# Ensure it's dead
Process.sleep(10)
# Force register it globally (simulating stale registration)
:global.re_register_name(@election_key, dead_pid)
# Start LeaderElection which should clean it up
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, _pid} = LeaderElection.start_link([])
# Wait for cleanup and election to occur
Process.sleep(150)
# Should have taken over leadership
assert LeaderElection.leader?() == true
end
end
describe "cluster mode behavior" do
setup do
Application.put_env(:aprsme, :cluster_enabled, true)
:ok
end
test "waits for cluster formation when enabled" do
Application.put_env(:aprsme, :election_max_cluster_wait_ms, 100)
Application.put_env(:aprsme, :election_cluster_check_interval_ms, 50)
on_exit(fn ->
Application.delete_env(:aprsme, :election_max_cluster_wait_ms)
Application.delete_env(:aprsme, :election_cluster_check_interval_ms)
end)
Phoenix.PubSub.subscribe(Aprsme.PubSub, "cluster:leadership")
{:ok, _pid} = LeaderElection.start_link([])
# Wait for force_election_timeout (max_cluster_wait_ms: 100) to fire
# and the process to become leader. The :force_election_timeout message
# triggers election and publishes {:leadership_change, _, true}.
assert_receive {:leadership_change, _, true}, 1000
assert LeaderElection.leader?() == true
end
end
describe "leadership verification" do
setup do
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, pid} = LeaderElection.start_link([])
assert LeaderElection.leader?() == true
{:ok, pid: pid}
end
test "steps down when global registration is lost", %{pid: pid} do
# Subscribe to leadership change notifications
Phoenix.PubSub.subscribe(Aprsme.PubSub, "cluster:leadership")
# Simulate losing the global registration (as happens during :global conflict resolution)
:global.unregister_name(@election_key)
# Force a leadership check, then use :sys.get_state to synchronously wait
# for the message to be processed (before the 100ms re-election timer fires)
send(pid, :check_leadership)
state = :sys.get_state(pid)
# Should have detected the loss and stepped down
assert state.is_leader == false
assert LeaderElection.leader_cached?() == false
# Should have notified about leadership loss
assert_received {:leadership_change, _, false}
end
test "steps down when another process holds the registration", %{pid: pid} do
Phoenix.PubSub.subscribe(Aprsme.PubSub, "cluster:leadership")
# Simulate another node winning the conflict resolution
imposter = spawn(fn -> Process.sleep(:infinity) end)
:global.re_register_name(@election_key, imposter)
# Force a leadership check
send(pid, :check_leadership)
state = :sys.get_state(pid)
# Should have stepped down
assert state.is_leader == false
assert LeaderElection.leader_cached?() == false
assert_received {:leadership_change, _, false}
# Clean up
Process.exit(imposter, :kill)
end
test "re-elects after stepping down", %{pid: pid} do
# Lose the registration
:global.unregister_name(@election_key)
# Force check — should step down and immediately queue :attempt_election.
send(pid, :check_leadership)
state = :sys.get_state(pid)
assert state.is_leader == false
# 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
describe "message handling" do
setup do
Application.put_env(:aprsme, :cluster_enabled, false)
{:ok, pid} = LeaderElection.start_link([])
{:ok, pid: pid}
end
test "handles unknown messages without crashing", %{pid: pid} do
send(pid, :unknown_message)
:sys.get_state(pid)
assert Process.alive?(pid)
assert LeaderElection.leader?() in [true, false]
end
test "handles check_leadership message", %{pid: pid} do
send(pid, :check_leadership)
:sys.get_state(pid)
assert Process.alive?(pid)
assert LeaderElection.leader?() in [true, false]
end
end
describe "get_cluster_aprs_status/0 clustered mode" do
setup do
original = Application.get_env(:aprsme, :cluster_enabled)
on_exit(fn -> Application.put_env(:aprsme, :cluster_enabled, original || false) end)
:ok
end
test "clustered mode aggregates local status with cluster_info" do
Application.put_env(:aprsme, :cluster_enabled, true)
status = LeaderElection.get_cluster_aprs_status()
assert %{connected: _, server: _, port: _} = status
# In clustered mode, cluster_info is added.
if Map.has_key?(status, :cluster_info) do
assert %{total_nodes: _, connected_nodes: _, leader_node: _, all_nodes: _} = status.cluster_info
assert Map.has_key?(status.cluster_info, :total_nodes)
assert Map.has_key?(status.cluster_info, :connected_nodes)
end
end
test "clustered get_cluster_aprs_status with registered leader pid hits get_leader_node_name pid branch" do
Application.put_env(:aprsme, :cluster_enabled, true)
# Pre-register a real pid under the @election_key so the get_leader_node_name
# branch (line 382: pid when is_pid(pid) -> pid |> node() |> to_string()).
:global.unregister_name(@election_key)
:global.register_name(@election_key, self())
try do
status = LeaderElection.get_cluster_aprs_status()
assert %{connected: _, server: _, port: _} = status
after
:global.unregister_name(@election_key)
end
end
end
describe "handle_info(:check_cluster_and_elect) direct callback" do
test "no-ops when election already forced" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: true
}
assert {:noreply, ^state} =
LeaderElection.handle_info(:check_cluster_and_elect, state)
end
test "reschedules itself when cluster has no peers yet" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: false
}
assert {:noreply, new_state} =
LeaderElection.handle_info(:check_cluster_and_elect, state)
# Still not forced — we're still waiting.
refute new_state.election_forced
end
end
describe "handle_info(:force_election_timeout) direct callback" do
test "no-ops when election already forced" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: true,
is_leader: false
}
assert {:noreply, ^state} =
LeaderElection.handle_info(:force_election_timeout, state)
end
test "no-ops when already leader" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: false,
is_leader: true
}
assert {:noreply, ^state} =
LeaderElection.handle_info(:force_election_timeout, state)
end
test "forces election when not yet forced and not leader" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: false,
is_leader: false
}
assert {:noreply, new_state} =
LeaderElection.handle_info(:force_election_timeout, state)
assert new_state.election_forced
end
end
describe "handle_info(:attempt_election) direct callback" do
setup do
:global.unregister_name(@election_key)
on_exit(fn ->
:global.unregister_name(@election_key)
end)
:ok
end
test "becomes leader and persists leadership when no other registration exists" do
state = %LeaderElection{
cluster_enabled: false,
is_leader: false,
leader_node: nil
}
assert {:noreply, new_state} =
LeaderElection.handle_info(:attempt_election, state)
assert new_state.is_leader == true
assert new_state.leader_node == node()
end
test "re-confirms leadership when already registered as self" do
# Pre-register ourselves as the leader.
:global.register_name(@election_key, self())
state = %LeaderElection{
cluster_enabled: false,
is_leader: false,
leader_node: nil
}
assert {:noreply, new_state} =
LeaderElection.handle_info(:attempt_election, state)
assert new_state.is_leader == true
end
end
describe "handle_call callbacks" do
test "leader? returns state.is_leader" do
state = %LeaderElection{is_leader: true}
assert {:reply, true, ^state} = LeaderElection.handle_call(:leader?, self(), state)
state2 = %LeaderElection{is_leader: false}
assert {:reply, false, ^state2} = LeaderElection.handle_call(:leader?, self(), state2)
end
test "current_leader returns state.leader_node" do
state = %LeaderElection{leader_node: :node_a}
assert {:reply, :node_a, ^state} = LeaderElection.handle_call(:current_leader, self(), state)
end
end
describe "get_cluster_aprs_status/0 detailed branches" do
test "returns a map with cluster_info when clustering is enabled and no peers connected" do
# Single-node cluster — Node.list() == []; get_cluster_wide_status
# falls into the "no connected statuses" branch.
Application.put_env(:aprsme, :cluster_enabled, true)
status = LeaderElection.get_cluster_aprs_status()
assert %{connected: _, server: _, port: _} = status
if Map.has_key?(status, :cluster_info) do
assert status.cluster_info.total_nodes >= 1
assert status.cluster_info.all_nodes != []
# leader_node is a string (including "none" when no leader).
assert byte_size(status.cluster_info.leader_node) > 0
end
end
end
describe "handle_info(:attempt_election) with another pid holding the global key" do
test "becomes non-leader when another process holds the registration (:no path)" do
# Pre-register a different pid (not the test process) so attempt_election
# sees a foreign pid and returns :no via check_own_registration.
other = spawn(fn -> Process.sleep(:infinity) end)
:global.unregister_name(@election_key)
:global.register_name(@election_key, other)
try do
state = %LeaderElection{
cluster_enabled: false,
is_leader: false,
leader_node: nil
}
assert {:noreply, new_state} =
LeaderElection.handle_info(:attempt_election, state)
# We got the :no path. leader_node should be the foreign pid's node.
assert new_state.is_leader == false
assert new_state.leader_node == node(other)
after
Process.exit(other, :kill)
:global.unregister_name(@election_key)
end
end
end
describe "init/1 in clustered mode (schedule_initial_election(true) path)" do
test "schedules :check_cluster_and_elect and :force_election_timeout" do
# Capture the test process — init/1 runs in this process and sends
# itself the cluster-check messages, so they land in our mailbox.
original = Application.get_env(:aprsme, :cluster_enabled)
Application.put_env(:aprsme, :cluster_enabled, true)
try do
assert {:ok, %{cluster_enabled: true}} = LeaderElection.init([])
# Both messages are scheduled with delays — won't arrive immediately
# but the function should have run successfully.
# The message :check_cluster_and_elect is scheduled 2s out — too long
# to wait for in a test. We just verify init succeeded.
after
Application.put_env(:aprsme, :cluster_enabled, original)
end
end
end
describe "schedule_initial_election delay branch" do
test "non-zero election_initial_delay_ms uses Process.send_after path" do
original = Application.get_env(:aprsme, :election_initial_delay_ms, 100)
Application.put_env(:aprsme, :election_initial_delay_ms, 50)
try do
# init/1 in non-clustered mode calls schedule_initial_election(false)
# which goes to the `else: Process.send_after(...)` branch when delay > 0.
original_cluster = Application.get_env(:aprsme, :cluster_enabled, false)
Application.put_env(:aprsme, :cluster_enabled, false)
try do
assert {:ok, _state} = LeaderElection.init([])
# The :attempt_election message is scheduled 50ms out — won't arrive within this test.
after
Application.put_env(:aprsme, :cluster_enabled, original_cluster)
end
after
Application.put_env(:aprsme, :election_initial_delay_ms, original)
end
end
end
describe "handle_info(:check_leadership) direct callback" do
test "no-op for a non-leader — reschedules and keeps state" do
state = %LeaderElection{is_leader: false, leader_node: nil}
assert {:noreply, new_state} =
LeaderElection.handle_info(:check_leadership, state)
assert new_state.is_leader == false
end
test "leader with intact global registration stays leader" do
# Pre-register ourselves globally so verify_leadership/1 sees pid == self().
:global.unregister_name(@election_key)
:global.register_name(@election_key, self())
state = %LeaderElection{is_leader: true, leader_node: node()}
assert {:noreply, new_state} =
LeaderElection.handle_info(:check_leadership, state)
assert new_state.is_leader == true
:global.unregister_name(@election_key)
end
test "leader whose registration was lost steps down" do
:global.unregister_name(@election_key)
state = %LeaderElection{is_leader: true, leader_node: node()}
assert {:noreply, new_state} =
LeaderElection.handle_info(:check_leadership, state)
assert new_state.is_leader == false
assert new_state.leader_node == nil
end
end
describe "handle_info(:force_election_timeout, state)" do
test "single-node mode warning branch when no other nodes" do
# not election_forced, not leader → goes into the if branch.
# Node.list() returns [] in this single-node test.
state = %LeaderElection{
cluster_enabled: true,
election_forced: false,
is_leader: false,
leader_node: nil
}
assert {:noreply, new_state} = LeaderElection.handle_info(:force_election_timeout, state)
assert new_state.election_forced == true
end
test "no-op branch when election already forced" do
state = %LeaderElection{
cluster_enabled: true,
election_forced: true,
is_leader: false,
leader_node: nil
}
assert {:noreply, new_state} = LeaderElection.handle_info(:force_election_timeout, state)
assert new_state == state
end
end
describe "handle_info(:check_cluster_and_elect, state)" do
test "no-op branch when election_forced is true" do
state = %LeaderElection{election_forced: true}
assert {:noreply, ^state} =
LeaderElection.handle_info(:check_cluster_and_elect, state)
end
test "reschedules itself when no nodes are connected (cluster not formed)" do
state = %LeaderElection{election_forced: false}
# Node.list() returns [] in single-node test mode → reschedules in 2s.
assert {:noreply, new_state} =
LeaderElection.handle_info(:check_cluster_and_elect, state)
assert new_state.election_forced == false
end
end
describe "handle_info/2 for unrecognized messages" do
test "logs and replies :noreply with state unchanged" do
state = %LeaderElection{is_leader: false}
assert {:noreply, ^state} = LeaderElection.handle_info(:something_unknown, state)
end
end
describe "handle_call(:current_leader, _from, state)" do
test "returns the leader_node from state" do
state = %LeaderElection{leader_node: :some@node}
assert {:reply, :some@node, ^state} = LeaderElection.handle_call(:current_leader, self(), state)
end
end
describe "terminate/2" do
test "non-leader terminate returns :ok without unregistering global" do
state = %LeaderElection{is_leader: false, leader_node: nil}
assert :ok = LeaderElection.terminate(:shutdown, state)
end
test "leader terminate logs and unregisters global" do
:global.unregister_name(@election_key)
:global.register_name(@election_key, self())
state = %LeaderElection{is_leader: true, leader_node: node()}
assert :ok = LeaderElection.terminate(:shutdown, state)
# The global registration should now be cleared.
assert :global.whereis_name(@election_key) == :undefined
end
end
end