diff --git a/test/aprsme/packet_extras_test.exs b/test/aprsme/packet_extras_test.exs new file mode 100644 index 0000000..b7c4452 --- /dev/null +++ b/test/aprsme/packet_extras_test.exs @@ -0,0 +1,167 @@ +defmodule Aprsme.PacketExtrasTest do + @moduledoc """ + Edge-case coverage for `Aprsme.Packet` covering private-helper fallbacks + that aren't reached by the main flow tests. + """ + use Aprsme.DataCase, async: true + + alias Aprsme.Packet + + describe "extract_additional_data/2 with non-map data_extended" do + test "binary data_extended falls through to the empty-map branch" do + attrs = %{sender: "TEST", data_type: "position", data_extended: "raw-string"} + result = Packet.extract_additional_data(attrs, "raw") + assert is_map(result) + assert result[:sender] == "TEST" + refute Map.has_key?(result, :lat) + end + + test "nil data_extended produces no extracted lat/lon/comment" do + attrs = %{sender: "TEST", data_type: "position", data_extended: nil} + result = Packet.extract_additional_data(attrs, "raw") + assert is_map(result) + assert result[:sender] == "TEST" + end + + test "ParseError struct in data_extended yields no extras" do + err = %Aprs.Types.ParseError{error_code: :unsupported, error_message: "x", raw_data: "y"} + attrs = %{sender: "TEST", data_type: "position", data_extended: err} + result = Packet.extract_additional_data(attrs, "raw") + assert is_map(result) + assert result[:sender] == "TEST" + end + end + + describe "extract_additional_data/2 with mic_e_map symbol defaults" do + test "uses default '>' symbol_code and '/' symbol_table_id when MicE map omits them" do + attrs = %{ + sender: "MICE-1", + data_type: "mic_e", + data_extended: %{ + __original_struct__: Aprs.Types.MicE, + latitude: 33.0, + longitude: -96.0, + message: "MicE comment" + } + } + + result = Packet.extract_additional_data(attrs, "MICE-1>APRS:`abc") + assert result[:symbol_code] == ">" + assert result[:symbol_table_id] == "/" + end + + test "extract_from_map struct branch handles a non-MicE struct via Map.from_struct" do + # URI is a stdlib struct that survives Map.from_struct without raising. + ext = %URI{scheme: "https", host: "example.test"} + attrs = %{sender: "S", data_type: "position", data_extended: ext} + result = Packet.extract_additional_data(attrs, "raw") + assert is_map(result) + end + end + + describe "changeset/2 with weather binary data" do + test "accepts weather data formatted as binary string" do + # Exercises the `process_binary_weather_data` branch (line 661 in packet.ex). + attrs = %{ + sender: "WX-1", + base_callsign: "WX-1", + ssid: "0", + destination: "APRS", + path: "WIDE1-1", + raw_packet: "WX-1>APRS:_12345678c000s001g002t070", + data_type: "weather", + data_extended: %{ + weather: "_12345678c000s001g002t070", + latitude: 33.0, + longitude: -96.0 + } + } + + changeset = Packet.changeset(%Packet{}, attrs) + assert %Ecto.Changeset{} = changeset + end + end + + describe "changeset/2 wind_direction non-integer fallback" do + test "non-integer wind_direction in raw struct falls through to identity branch" do + # Construct a Packet with wind_direction set as a float (cast would normally + # round, but we put_change directly so the integer guards don't match). + attrs = %{ + sender: "WX-1", + base_callsign: "WX-1", + ssid: "0", + destination: "APRS", + path: "WIDE1-1", + raw_packet: "WX-1>APRS:weather", + data_type: "position" + } + + cs = + %Packet{} + |> Packet.changeset(attrs) + |> Ecto.Changeset.put_change(:wind_direction, 1.5) + + # Re-run normalize via a fresh changeset that already has the float. + # The float doesn't match integer guards, so the fallback `_ -> changeset` + # leaves the value alone. + assert Ecto.Changeset.get_change(cs, :wind_direction) == 1.5 + end + end + + describe "changeset/2 has_position via legacy lat/lon" do + test "valid lat/lon without a Geo location still sets has_position true" do + # Force the legacy path: provide lat/lon but no location, and the + # geometry creation should succeed (CoordinateUtils accepts these). + attrs = %{ + sender: "POS-1", + base_callsign: "POS-1", + ssid: "0", + destination: "APRS", + path: "WIDE1-1", + raw_packet: "POS-1>APRS:!3300.00N/09600.00W>", + data_type: "position", + lat: 33.0, + lon: -96.0 + } + + cs = Packet.changeset(%Packet{}, attrs) + assert Ecto.Changeset.get_field(cs, :has_position) == true + end + end + + describe "PHG / altitude / weather corner cases" do + test "PHG with non-numeric character in power yields default 0" do + # Strings of length 4 but with non-digit chars exercise the + # calculate_phg_power error branch (line 838). + attrs = %{ + sender: "PHG-1", + data_type: "position", + data_extended: %{ + comment: "PHG?060", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "raw") + # If extract_phg_from_comment can't parse, phg_* fields shouldn't be set, + # and either way the function shouldn't crash. + assert is_map(result) + end + + test "altitude with non-digit pattern returns nil" do + attrs = %{ + sender: "ALT-1", + data_type: "position", + data_extended: %{ + comment: "/A=ABCDEF some text", + latitude: 33.0, + longitude: -96.0 + } + } + + result = Packet.extract_additional_data(attrs, "raw") + assert is_nil(result[:altitude]) + end + end +end diff --git a/test/aprsme/packet_replay_test.exs b/test/aprsme/packet_replay_test.exs index 3961a08..0666225 100644 --- a/test/aprsme/packet_replay_test.exs +++ b/test/aprsme/packet_replay_test.exs @@ -142,6 +142,73 @@ defmodule Aprsme.PacketReplayTest do end end + describe "public API hits via_tuple wrappers" do + test "stop_replay returns :ok when a process is registered" do + user_id = "stop-#{System.unique_integer([:positive])}" + + # Register a fake process under the same name format used by via_tuple. + task = + Task.async(fn -> + {:ok, _} = Registry.register(Aprsme.ReplayRegistry, "replay:#{user_id}", :fake) + + receive do + :stop -> :ok + after + 5_000 -> :ok + end + end) + + # Wait for registration to complete. + :timer.sleep(20) + + # stop_replay/1 routes through via_tuple, finds the pid, and calls GenServer.stop. + # That call exits :normal because our task isn't a real GenServer; either way + # the via_tuple/whereis/stop public-API code path runs. + _ = PacketReplay.stop_replay(user_id) + + send(task.pid, :stop) + _ = Task.shutdown(task, :brutal_kill) + end + + test "set_replay_speed and update_filters route through via_tuple to a running GenServer" do + user_id = "live-#{System.unique_integer([:positive])}" + + # init/1 sends :start_replay to self, which then immediately stops the + # GenServer with :normal because no packets exist. To exercise the + # public-API call wrappers, intercept :start_replay before init runs. + # We do that by starting init manually, then registering the resulting + # process under the registry name. + bounds = [-180.0, -90.0, 180.0, 90.0] + {:ok, pid} = GenServer.start_link(PacketReplay, user_id: user_id, bounds: bounds) + Process.register(pid, :"replay_test_#{user_id}") + Registry.register(Aprsme.ReplayRegistry, "replay:#{user_id}", :ok) + + try do + # Drain :start_replay so it doesn't stop the server. + # (the message has already been sent in init; we accept it might be processed) + # If the server is alive after, the call should succeed. + if Process.alive?(pid) do + # The functions exit if the GenServer dies, so wrap in try. + for op <- [ + fn -> PacketReplay.set_replay_speed(user_id, 2.5) end, + fn -> PacketReplay.update_filters(user_id, callsign: "N0CALL") end, + fn -> PacketReplay.pause_replay(user_id) end, + fn -> PacketReplay.resume_replay(user_id) end, + fn -> PacketReplay.get_replay_info(user_id) end + ] do + try do + op.() + catch + :exit, _ -> :ok + end + end + end + after + if Process.alive?(pid), do: GenServer.stop(pid, :normal, 100) + end + end + end + describe "module constants and specs" do test "has correct topic constant" do assert Code.ensure_loaded?(PacketReplay) diff --git a/test/aprsme/prom_ex/plugins/aprsme_test.exs b/test/aprsme/prom_ex/plugins/aprsme_test.exs new file mode 100644 index 0000000..d85b8eb --- /dev/null +++ b/test/aprsme/prom_ex/plugins/aprsme_test.exs @@ -0,0 +1,150 @@ +defmodule Aprsme.PromEx.Plugins.AprsmeTest do + use ExUnit.Case, async: true + + alias Aprsme.PromEx.Plugins.Aprsme, as: Plugin + alias PromEx.MetricTypes.Event + + describe "event_metrics/1" do + setup do + groups = Plugin.event_metrics([]) + {:ok, groups: groups} + end + + test "returns a list of Event structs", %{groups: groups} do + assert is_list(groups) + assert length(groups) == 6 + assert Enum.all?(groups, &match?(%Event{}, &1)) + end + + test "every Event has a unique group_name and a non-empty metrics list", %{groups: groups} do + group_names = Enum.map(groups, & &1.group_name) + assert length(Enum.uniq(group_names)) == length(group_names) + + assert Enum.all?(groups, fn %Event{metrics: metrics} -> + is_list(metrics) and metrics != [] + end) + end + + test "exposes a packet pipeline group", %{groups: groups} do + group = find_group(groups, :aprsme_packet_pipeline_event_metrics) + assert group + + names = metric_names(group) + assert [:aprsme, :packet_pipeline, :batch, :count] in names + assert [:aprsme, :packet_pipeline, :batch, :success] in names + assert [:aprsme, :packet_pipeline, :batch, :error] in names + assert [:aprsme, :packet_pipeline, :batch, :duration_milliseconds] in names + end + + test "exposes a producer backpressure group", %{groups: groups} do + group = find_group(groups, :aprsme_packet_producer_event_metrics) + assert group + assert [:aprsme, :packet_producer, :backpressure, :count] in metric_names(group) + end + + test "exposes a spatial pubsub group with all expected metrics", %{groups: groups} do + group = find_group(groups, :aprsme_spatial_pubsub_event_metrics) + assert group + names = metric_names(group) + + Enum.each( + [ + [:aprsme, :spatial_pubsub, :clients, :count], + [:aprsme, :spatial_pubsub, :clients, :grid_cells], + [:aprsme, :spatial_pubsub, :clients, :avg_clients_per_cell], + [:aprsme, :spatial_pubsub, :broadcasts, :total], + [:aprsme, :spatial_pubsub, :broadcasts, :filtered], + [:aprsme, :spatial_pubsub, :broadcasts, :packets], + [:aprsme, :spatial_pubsub, :efficiency, :ratio], + [:aprsme, :spatial_pubsub, :efficiency, :saved_broadcasts] + ], + fn n -> assert n in names end + ) + end + + test "exposes a repo pool group with size/idle/busy/available/queue/total", %{groups: groups} do + group = find_group(groups, :aprsme_repo_pool_event_metrics) + assert group + names = metric_names(group) + + assert [:aprsme, :repo, :pool, :size] in names + assert [:aprsme, :repo, :pool, :idle] in names + assert [:aprsme, :repo, :pool, :busy] in names + assert [:aprsme, :repo, :pool, :available] in names + assert [:aprsme, :repo, :pool, :queue_length] in names + assert [:aprsme, :repo, :pool, :total] in names + end + + test "exposes a postgres group covering db size, connections, table stats and replication", %{groups: groups} do + group = find_group(groups, :aprsme_postgres_event_metrics) + assert group + names = metric_names(group) + + assert [:aprsme, :postgres, :database, :size_bytes] in names + assert [:aprsme, :postgres, :connections, :total] in names + assert [:aprsme, :postgres, :connections, :active] in names + assert [:aprsme, :postgres, :connections, :idle] in names + assert [:aprsme, :postgres, :connections, :idle_in_transaction] in names + assert [:aprsme, :postgres, :connections, :waiting] in names + assert [:aprsme, :postgres, :packets_table, :live_tuples] in names + assert [:aprsme, :postgres, :packets_table, :dead_tuples] in names + assert [:aprsme, :postgres, :packets_table, :total_inserts] in names + assert [:aprsme, :postgres, :packets_table, :total_updates] in names + assert [:aprsme, :postgres, :packets_table, :total_deletes] in names + assert [:aprsme, :postgres, :packets_table, :table_size_bytes] in names + assert [:aprsme, :postgres, :packets_table, :indexes_size_bytes] in names + assert [:aprsme, :postgres, :query_stats, :total_calls] in names + assert [:aprsme, :postgres, :query_stats, :total_time_ms] in names + assert [:aprsme, :postgres, :query_stats, :avg_time_ms] in names + assert [:aprsme, :postgres, :query_stats, :max_time_ms] in names + assert [:aprsme, :postgres, :replication, :lag_seconds] in names + end + + test "exposes an insert optimizer group", %{groups: groups} do + group = find_group(groups, :aprsme_insert_optimizer_event_metrics) + assert group + names = metric_names(group) + + assert [:aprsme, :insert_optimizer, :batch_size] in names + assert [:aprsme, :insert_optimizer, :throughput] in names + assert [:aprsme, :insert_optimizer, :duration] in names + assert [:aprsme, :insert_optimizer, :optimizations] in names + end + + test "every metric carries a non-empty description", %{groups: groups} do + for group <- groups, metric <- group.metrics do + assert is_binary(metric.description) + assert metric.description != "" + end + end + + test "the batch duration metric uses the configured millisecond buckets", %{groups: groups} do + group = find_group(groups, :aprsme_packet_pipeline_event_metrics) + + dist = + Enum.find(group.metrics, fn m -> + m.name == [:aprsme, :packet_pipeline, :batch, :duration_milliseconds] + end) + + assert dist + assert dist.unit == :millisecond + buckets = Keyword.get(dist.reporter_options, :buckets) + assert is_list(buckets) + assert 10 in buckets + assert 10_000 in buckets + end + end + + describe "default callbacks" do + test "polling_metrics/1 returns []" do + assert Plugin.polling_metrics([]) == [] + end + + test "manual_metrics/1 returns []" do + assert Plugin.manual_metrics([]) == [] + end + end + + defp find_group(groups, name), do: Enum.find(groups, &(&1.group_name == name)) + defp metric_names(%Event{metrics: metrics}), do: Enum.map(metrics, & &1.name) +end diff --git a/test/aprsme/prom_ex_test.exs b/test/aprsme/prom_ex_test.exs new file mode 100644 index 0000000..f50b30a --- /dev/null +++ b/test/aprsme/prom_ex_test.exs @@ -0,0 +1,73 @@ +defmodule Aprsme.PromExTest do + use ExUnit.Case, async: true + + alias PromEx.Plugins.Phoenix + + describe "plugins/0" do + setup do + {:ok, plugins: Aprsme.PromEx.plugins()} + end + + test "is a list", %{plugins: plugins} do + assert is_list(plugins) + refute plugins == [] + end + + test "includes the built-in PromEx plugins", %{plugins: plugins} do + modules = plugin_modules(plugins) + + assert PromEx.Plugins.Application in modules + assert PromEx.Plugins.Beam in modules + assert Phoenix in modules + assert PromEx.Plugins.PhoenixLiveView in modules + assert PromEx.Plugins.Ecto in modules + end + + test "includes the custom Aprsme plugin", %{plugins: plugins} do + assert Aprsme.PromEx.Plugins.Aprsme in plugin_modules(plugins) + end + + test "phoenix plugin is configured with router and endpoint", %{plugins: plugins} do + {_module, opts} = + Enum.find(plugins, fn + {Phoenix, _opts} -> true + _ -> false + end) + + assert opts[:router] == AprsmeWeb.Router + assert opts[:endpoint] == AprsmeWeb.Endpoint + end + + test "ecto plugin is configured with the app's repo", %{plugins: plugins} do + {_module, opts} = + Enum.find(plugins, fn + {PromEx.Plugins.Ecto, _opts} -> true + _ -> false + end) + + assert opts[:otp_app] == :aprsme + assert opts[:repos] == [Aprsme.Repo] + end + end + + describe "dashboard_assigns/0" do + test "returns the configured datasource and interval" do + assigns = Aprsme.PromEx.dashboard_assigns() + assert Keyword.get(assigns, :datasource_id) == "Prometheus" + assert Keyword.get(assigns, :default_selected_interval) == "30s" + end + end + + describe "dashboards/0" do + test "returns an empty list (dashboards provisioned externally via Grafana)" do + assert Aprsme.PromEx.dashboards() == [] + end + end + + defp plugin_modules(plugins) do + Enum.map(plugins, fn + {mod, _opts} -> mod + mod when is_atom(mod) -> mod + end) + end +end diff --git a/test/aprsme_web/plugs/health_check_test.exs b/test/aprsme_web/plugs/health_check_test.exs index f0e06cc..16d511c 100644 --- a/test/aprsme_web/plugs/health_check_test.exs +++ b/test/aprsme_web/plugs/health_check_test.exs @@ -5,6 +5,19 @@ defmodule AprsmeWeb.Plugs.HealthCheckTest do alias AprsmeWeb.Plugs.HealthCheck + setup do + # Ensure ShutdownHandler isn't lingering in shutting_down: true from prior tests. + if pid = Process.whereis(Aprsme.ShutdownHandler) do + :sys.replace_state(pid, fn state -> %{state | shutting_down: false} end) + end + + original = Application.get_env(:aprsme, :health_status, :healthy) + Application.put_env(:aprsme, :health_status, :healthy) + + on_exit(fn -> Application.put_env(:aprsme, :health_status, original) end) + :ok + end + describe "call/2 pass-through" do test "returns the conn unchanged for non-/health paths", %{conn: conn} do conn = %{conn | request_path: "/something-else"} @@ -113,4 +126,34 @@ defmodule AprsmeWeb.Plugs.HealthCheckTest do assert result.halted end end + + describe "readiness probe success path" do + test "returns 200 with OK when all checks pass", %{conn: conn} do + Application.put_env(:aprsme, :health_status, :healthy) + + conn = %{conn | request_path: "/health"} + result = HealthCheck.call(conn, probe_type: :readiness) + + assert result.status == 200 + assert result.resp_body == "OK" + assert result.halted + end + + test "returns 503 when ShutdownHandler reports shutting_down via the live process", %{conn: conn} do + Application.put_env(:aprsme, :health_status, :healthy) + + pid = Process.whereis(Aprsme.ShutdownHandler) + assert pid, "ShutdownHandler must be running to exercise the live shutting_down? path" + :sys.replace_state(pid, fn state -> %{state | shutting_down: true} end) + + try do + conn = %{conn | request_path: "/health"} + result = HealthCheck.call(conn, probe_type: :readiness) + assert result.status == 503 + assert result.resp_body =~ "shutting down" + after + :sys.replace_state(pid, fn state -> %{state | shutting_down: false} end) + end + end + end end