defmodule Aprsme.Cluster.ConnectionManagerTest do use ExUnit.Case, async: false alias Aprsme.Cluster.ConnectionManager alias Aprsme.Cluster.LeaderElection setup do # Stop any running ConnectionManager process if Process.whereis(ConnectionManager) do GenServer.stop(ConnectionManager) end # Stop any running LeaderElection process if Process.whereis(LeaderElection) do GenServer.stop(LeaderElection) end # Ensure DynamicSupervisor is started case Process.whereis(Aprsme.DynamicSupervisor) do nil -> {:ok, _} = DynamicSupervisor.start_link(strategy: :one_for_one, name: Aprsme.DynamicSupervisor) pid when is_pid(pid) -> :ok end on_exit(fn -> # Try to stop processes but don't crash if they're already dead try do if Process.whereis(ConnectionManager) do GenServer.stop(ConnectionManager, :normal, 100) end catch :exit, _ -> :ok end try do if Process.whereis(LeaderElection) do GenServer.stop(LeaderElection, :normal, 100) end catch :exit, _ -> :ok end # Clean up any IsSupervisor processes if DynamicSupervisor exists if Process.whereis(Aprsme.DynamicSupervisor) do try do children = DynamicSupervisor.which_children(Aprsme.DynamicSupervisor) Enum.each(children, fn {_, pid, _, _} -> if is_pid(pid) do DynamicSupervisor.terminate_child(Aprsme.DynamicSupervisor, pid) end end) catch :exit, _ -> :ok end end end) :ok end describe "start_link/1" do test "starts the GenServer process" do assert {:ok, pid} = ConnectionManager.start_link([]) assert Process.alive?(pid) assert Process.whereis(ConnectionManager) == pid end test "registers with the module name" do {:ok, _pid} = ConnectionManager.start_link([]) assert Process.whereis(ConnectionManager) end end describe "initialization" do setup do # Start a minimal LeaderElection to prevent crashes Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() :ok end test "subscribes to leadership changes" do {:ok, pid} = ConnectionManager.start_link([]) # Verify subscription by publishing a test message Phoenix.PubSub.broadcast( Aprsme.PubSub, "cluster:leadership", {:leadership_change, :test_node, false} ) # 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) end test "schedules initial state check" do {:ok, pid} = ConnectionManager.start_link([]) # 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) end end describe "leadership state handling" do setup do # Start LeaderElection in non-clustered mode so it becomes leader Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() # Start ConnectionManager {:ok, pid} = ConnectionManager.start_link([]) {:ok, pid: pid} end test "handles initial state check when leader", %{pid: pid} do send(pid, :check_initial_state) :sys.get_state(pid) # Should handle without crashing assert Process.alive?(pid) # Verify handle_info handles unknown messages assert {:noreply, _} = ConnectionManager.handle_info(:unknown_msg, %{connection_started: false}) end test "handles initial state check when not leader", %{pid: pid} do send(pid, :check_initial_state) :sys.get_state(pid) # Should handle without crashing assert Process.alive?(pid) # Verify handle_info handles unknown messages assert {:noreply, _} = ConnectionManager.handle_info(:unknown_msg, %{connection_started: false}) end end describe "leadership change handling" do setup do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) :sys.get_state(pid) {:ok, pid: pid} end test "starts connection when becoming leader", %{pid: pid} do send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Should handle the message without crashing assert Process.alive?(pid) # Verify handle_info handles leadership changes directly assert {:noreply, _} = ConnectionManager.handle_info({:leadership_change, :other@node, true}, %{connection_started: false}) end test "stops connection when losing leadership", %{pid: pid} do # First become leader and start connection send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Then lose leadership send(pid, {:leadership_change, node(), false}) :sys.get_state(pid) # Should handle the message without crashing assert Process.alive?(pid) # Verify handle_info handles losing leadership directly assert {:noreply, %{connection_started: false}} = ConnectionManager.handle_info({:leadership_change, node(), false}, %{connection_started: true}) end test "ignores leadership changes for other nodes", %{pid: pid} do other_node = :other@host send(pid, {:leadership_change, other_node, true}) :sys.get_state(pid) # Should not affect this node assert Process.alive?(pid) # Verify handle_info ignores other nodes directly state = %{connection_started: false} assert {:noreply, ^state} = ConnectionManager.handle_info({:leadership_change, other_node, true}, state) end test "does not start connection twice", %{pid: pid} do # Become leader twice send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Should handle duplicate leadership without issues assert Process.alive?(pid) # Verify handle_info handles duplicate leadership assert {:noreply, _} = ConnectionManager.handle_info({:leadership_change, node(), true}, %{connection_started: true}) end end describe "APRS connection management" do setup do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) :sys.get_state(pid) {:ok, pid: pid} end test "handles IsSupervisor already started error", %{pid: pid} do send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Should handle gracefully even if IsSupervisor fails to start assert Process.alive?(pid) # Verify handle_info handles unknown messages assert {:noreply, _} = ConnectionManager.handle_info(:unknown_msg, %{connection_started: false}) end test "handles IsSupervisor start failure", %{pid: pid} do # Define a failing IsSupervisor defmodule FailingIsSupervisor do @moduledoc false def start_link(_opts) do {:error, :intentional_failure} end end send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Should handle failure gracefully assert Process.alive?(pid) end end describe "handle_leadership_change direct callbacks" do test "leader notification for a different node is a no-op" do # Send via PubSub message format — should fall through the no-op clause. state = %{connection_started: false} # handle_info is called on the server; we can't easily test the # private helper directly, but we can verify the public PubSub # path is idempotent for foreign nodes. assert {:noreply, ^state} = ConnectionManager.handle_info({:leadership_change, :other@node, true}, state) end test "gaining leadership when we already have it is a no-op" do state = %{connection_started: true} # We're the local node but we're already running — should be a no-op # path via handle_leadership_change(true, true, %{connection_started: true}). # Actually that clause isn't defined — our node being told we won again # falls through the other clauses. assert {:noreply, _new_state} = ConnectionManager.handle_info({:leadership_change, node(), true}, state) end test "losing leadership when not running is a no-op" do state = %{connection_started: false} assert {:noreply, ^state} = ConnectionManager.handle_info({:leadership_change, node(), false}, state) end end describe "connection lifecycle" do setup do # Ensure LeaderElection is started Application.put_env(:aprsme, :cluster_enabled, false) {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() {:ok, pid} = ConnectionManager.start_link([]) {:ok, pid: pid} end test "full lifecycle - become leader, lose leadership", %{pid: pid} do :sys.get_state(pid) # Become leader send(pid, {:leadership_change, node(), true}) :sys.get_state(pid) # Verify connection started (check state indirectly) assert Process.alive?(pid) # Lose leadership send(pid, {:leadership_change, node(), false}) :sys.get_state(pid) # Verify still alive after stopping connection assert Process.alive?(pid) # Verify handle_info handles losing leadership directly assert {:noreply, %{connection_started: false}} = ConnectionManager.handle_info({:leadership_change, node(), false}, %{connection_started: true}) end end describe "handle_info direct callbacks" do test "handle_info/2 ignores unknown messages without changing state" do state = %{connection_started: false} assert {:noreply, ^state} = ConnectionManager.handle_info(:totally_unrelated, state) assert {:noreply, ^state} = ConnectionManager.handle_info({:foo, :bar}, state) end test "handle_info/2 :check_initial_state when not leader returns state unchanged" do # Ensure LeaderElection is NOT running so leader_check returns false (via :exit catch) if Process.whereis(LeaderElection) do GenServer.stop(LeaderElection) end state = %{connection_started: false} assert {:noreply, ^state} = ConnectionManager.handle_info(:check_initial_state, state) end test "handle_info/2 :check_initial_state with running leader starts connection" do Application.put_env(:aprsme, :cluster_enabled, false) case Process.whereis(LeaderElection) do nil -> {:ok, _} = LeaderElection.start_link([]) LeaderElection.leader?() _pid -> :ok end state = %{connection_started: false} # Triggers start_aprs_connection via DynamicSupervisor — may succeed, # fail, or report already_started, but must not crash. assert {:noreply, new_state} = ConnectionManager.handle_info(:check_initial_state, state) assert new_state.connection_started == true end test "handle_info/2 leadership_change — us becoming leader starts connection" do state = %{connection_started: false} assert {:noreply, new_state} = ConnectionManager.handle_info({:leadership_change, node(), true}, state) assert new_state.connection_started == true end test "handle_info/2 leadership_change — us losing leadership stops connection" do state = %{connection_started: true} assert {:noreply, new_state} = ConnectionManager.handle_info({:leadership_change, node(), false}, state) assert new_state.connection_started == false end end describe "terminate/2" do test "unsubscribes from the cluster:leadership topic and returns :ok" do state = %{connection_started: false} assert :ok = ConnectionManager.terminate(:normal, state) end end end