test: expand coverage on cluster, shutdown, signal, packets helpers

This commit is contained in:
Graham McIntire 2026-04-24 09:38:16 -05:00
parent 340707ebf9
commit 84a61bd95c
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 226 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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