diff --git a/test/aprsme/cluster/connection_manager_test.exs b/test/aprsme/cluster/connection_manager_test.exs index a9d8143..17b2dc5 100644 --- a/test/aprsme/cluster/connection_manager_test.exs +++ b/test/aprsme/cluster/connection_manager_test.exs @@ -303,4 +303,71 @@ defmodule Aprsme.Cluster.ConnectionManagerTest do assert Process.alive?(pid) 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([]) + Process.sleep(200) + + _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 diff --git a/test/aprsme/cluster/leader_election_test.exs b/test/aprsme/cluster/leader_election_test.exs index 113d1f2..5c46b0e 100644 --- a/test/aprsme/cluster/leader_election_test.exs +++ b/test/aprsme/cluster/leader_election_test.exs @@ -479,4 +479,60 @@ defmodule Aprsme.Cluster.LeaderElectionTest do 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 is_map(status) + + if Map.has_key?(status, :cluster_info) do + assert status.cluster_info.total_nodes >= 1 + assert is_list(status.cluster_info.all_nodes) + # leader_node is a string (including "none" when no leader). + assert is_binary(status.cluster_info.leader_node) + 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 end diff --git a/test/aprsme/device_identification_test.exs b/test/aprsme/device_identification_test.exs index c092df4..ff55aac 100644 --- a/test/aprsme/device_identification_test.exs +++ b/test/aprsme/device_identification_test.exs @@ -92,6 +92,18 @@ defmodule Aprsme.DeviceIdentificationTest do test "returns empty list for unknown manufacturer" do assert DeviceIdentification.known_models("Unknown") == [] end + + test "returns list of known models for Byonics" do + models = DeviceIdentification.known_models("Byonics") + assert "TinyTrack3" in models + assert "TinyTrack4" in models + end + + test "returns list of known models for SCS GmbH & Co." do + models = DeviceIdentification.known_models("SCS GmbH & Co.") + assert "P4dragon DR-7400 modems" in models + assert "P4dragon DR-7800 modems" in models + end end describe "upsert_devices/1" do diff --git a/test/aprsme/packets_test.exs b/test/aprsme/packets_test.exs index e05bce0..5f90055 100644 --- a/test/aprsme/packets_test.exs +++ b/test/aprsme/packets_test.exs @@ -367,6 +367,58 @@ defmodule Aprsme.PacketsTest do # Either stores successfully without position or validation fails. assert match?({:ok, _}, result) or match?({:error, _}, result) end + + test "extracts southern-hemisphere coordinates from a MicE struct via apply_direction" do + packet_data = %{ + sender: "MICESOUTH", + base_callsign: "MICESOUTH", + ssid: "0", + destination: "APRS", + raw_packet: "MICESOUTH>APRS", + data_type: "mic_e", + data_extended: %MicE{ + lat_degrees: 33, + lat_minutes: 30, + lat_direction: :south, + lon_degrees: 96, + lon_minutes: 30, + lon_direction: :east + } + } + + assert {:ok, packet} = Packets.store_packet(packet_data) + # :south branch must negate the latitude; :east keeps longitude positive. + # Packet.lat is stored as Decimal — compare via Decimal.to_float. + lat = Decimal.to_float(packet.lat) + lon = Decimal.to_float(packet.lon) + assert lat < 0 + assert lon > 0 + end + + test "extracts northern + western coordinates from a MicE struct via apply_direction" do + packet_data = %{ + sender: "MICENW", + base_callsign: "MICENW", + ssid: "0", + destination: "APRS", + raw_packet: "MICENW>APRS", + data_type: "mic_e", + data_extended: %MicE{ + lat_degrees: 33, + lat_minutes: 30, + lat_direction: :north, + lon_degrees: 96, + lon_minutes: 30, + lon_direction: :west + } + } + + assert {:ok, packet} = Packets.store_packet(packet_data) + lat = Decimal.to_float(packet.lat) + lon = Decimal.to_float(packet.lon) + assert lat > 0 + assert lon < 0 + end end describe "store_packet/1 error paths" do diff --git a/test/aprsme/shutdown_handler_test.exs b/test/aprsme/shutdown_handler_test.exs index 9765d72..3d00889 100644 --- a/test/aprsme/shutdown_handler_test.exs +++ b/test/aprsme/shutdown_handler_test.exs @@ -123,4 +123,26 @@ defmodule Aprsme.ShutdownHandlerTest do assert function_exported?(ShutdownHandler, :shutting_down?, 0) end end + + describe "terminate/2 with abnormal reason" do + test "runs drain path with a tiny drain_timeout and returns :ok" do + # Use a near-zero drain_timeout so the Process.sleep inside the graceful + # drain path is effectively instant. This exercises mark_unhealthy + + # stop_accepting_connections + Process.sleep without blocking the suite. + prev_status = Application.get_env(:aprsme, :health_status) + state = %{shutting_down: false, drain_timeout: 1} + + try do + assert :ok = ShutdownHandler.terminate(:killed, state) + # Drain path flips the app's health status to :draining. + assert Application.get_env(:aprsme, :health_status) == :draining + after + if prev_status do + Application.put_env(:aprsme, :health_status, prev_status) + else + Application.delete_env(:aprsme, :health_status) + end + end + end + end end diff --git a/test/aprsme/signal_handler_test.exs b/test/aprsme/signal_handler_test.exs index b639baa..ffefea4 100644 --- a/test/aprsme/signal_handler_test.exs +++ b/test/aprsme/signal_handler_test.exs @@ -10,8 +10,23 @@ defmodule Aprsme.SignalHandlerTest do assert SignalHandler.handle_info({:other, "msg"}, state) == {:noreply, state} end - # We can't safely exercise the :sigterm clause here — it calls - # Aprsme.ShutdownHandler.shutdown() which would actually shut the app down. + test "sigterm branch spawn_links a shutdown task without crashing the caller" do + # Trap exits so the spawn_link from handle_info doesn't bring the test down + # when the shutdown task inevitably fails (ShutdownHandler is either not + # running or would actually stop the VM if it were). + Process.flag(:trap_exit, true) + + assert {:noreply, :state_x} = + SignalHandler.handle_info({:signal, :sigterm}, :state_x) + + # Drain any :EXIT messages from the spawned task; we don't care about + # the outcome, only that the callback ran. + receive do + {:EXIT, _pid, _reason} -> :ok + after + 200 -> :ok + end + end end describe "init/1" do