defmodule Aprsme.IsTest do @moduledoc """ Tests for Aprsme.Is GenServer — APRS-IS connection management. """ use ExUnit.Case, async: false use Aprsme.DataCase import ExUnit.CaptureLog alias Ecto.Adapters.SQL.Sandbox # Helper to build a default GenServer state for direct callback testing defp build_state(overrides \\ %{}) do timer = Process.send_after(self(), :noop_timer, to_timeout(minute: 5)) keepalive_timer = Process.send_after(self(), :noop_keepalive, to_timeout(minute: 5)) base = %{ server: "mock.aprs.test", port: 14_580, socket: nil, timer: timer, keepalive_timer: keepalive_timer, connected_at: DateTime.utc_now(), packet_stats: %{ total_packets: 0, last_packet_at: nil, packets_per_second: 0, last_second_count: 0, last_second_timestamp: System.system_time(:second) }, buffer: "", login_params: %{ user_id: "TEST", passcode: "-1", filter: "r/33/-96/100" }, backpressure_active: false, safety_valve_timer: nil, failure_started_at: nil } Map.merge(base, overrides) end # Ensure ETS table exists for tests that need it defp ensure_ets_table do case :ets.info(:aprsme) do :undefined -> :ets.new(:aprsme, [:set, :public, :named_table, write_concurrency: true]) :ets.insert(:aprsme, {:message_number, 0}) _ -> :ok end end describe "init/1" do test "stops in test environment" do assert {:stop, :test_environment_disabled} = Aprsme.Is.init([]) end test "start_link returns error in test environment" do Process.flag(:trap_exit, true) result = Aprsme.Is.start_link([]) assert {:error, :test_environment_disabled} = result end end describe "dispatch/1" do test "handles unverified login response" do Logger.configure(level: :warning) log = capture_log(fn -> result = Aprsme.Is.dispatch("# logresp TEST unverified, server T2TEXAS") assert result == :ok end) Logger.configure(level: :error) assert log =~ "APRS-IS login unverified" end test "handles accepted login response" do Logger.configure(level: :info) log = capture_log(fn -> result = Aprsme.Is.dispatch("# logresp TEST verified, server T2TEXAS") assert result == :ok end) Logger.configure(level: :error) assert log =~ "APRS-IS login accepted" end test "handles comment lines" do Logger.configure(level: :debug) log = capture_log(fn -> result = Aprsme.Is.dispatch("# some server comment") assert result == :ok end) Logger.configure(level: :error) assert log =~ "COMMENT" end test "handles empty string" do assert Aprsme.Is.dispatch("") == nil end test "dispatches valid APRS position packet" do ensure_ets_table() # A real APRS position packet raw = "W5ISP-1>APRS,TCPIP*:!3300.00N/09600.00W#PHG2360 Testing" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) # Should not log a parse error refute log =~ "PARSE ERROR" end test "dispatches invalid packet and stores bad packet" do Logger.configure(level: :debug) log = capture_log(fn -> Aprsme.Is.dispatch("totally invalid data that cannot be parsed") end) Logger.configure(level: :warning) # Should not crash — the function handles errors gracefully assert log =~ "PARSE ERROR" end test "handles minimal valid APRS packet with empty body" do Logger.configure(level: :debug) log = capture_log(fn -> # "X>Y:" is a minimal valid APRS packet (sender X, destination Y, empty body). # Aprs.parse/1 returns {:ok, ...} for this input, so no PARSE ERROR is emitted. Aprsme.Is.dispatch("X>Y:") end) Logger.configure(level: :warning) refute log =~ "PARSE ERROR" end end describe "handle_call(:get_status, ...)" do test "returns status when connected (socket present)" do connected_at = DateTime.utc_now() state = build_state(%{ socket: :fake_socket, connected_at: connected_at }) {:reply, status, ^state} = Aprsme.Is.handle_call(:get_status, {self(), make_ref()}, state) assert status.connected == true assert status.server == "mock.aprs.test" assert status.port == 14_580 assert status.login_id == "TEST" assert status.filter == "r/33/-96/100" assert status.connected_at == connected_at assert status.uptime_seconds >= 0 assert status.packet_stats.total_packets == 0 end test "returns disconnected status when socket is nil" do state = build_state(%{socket: nil}) {:reply, status, ^state} = Aprsme.Is.handle_call(:get_status, {self(), make_ref()}, state) assert status.connected == false assert status.uptime_seconds == 0 assert status.connected_at == nil end test "converts charlist server to string in status" do state = build_state(%{server: ~c"dallas.aprs2.net"}) {:reply, status, _state} = Aprsme.Is.handle_call(:get_status, {self(), make_ref()}, state) assert status.server == "dallas.aprs2.net" end end describe "handle_call({:send_message, ...}, ...)" do test "returns error when not connected (socket nil)" do state = build_state(%{socket: nil}) {:reply, result, ^state} = Aprsme.Is.handle_call({:send_message, "test"}, {self(), make_ref()}, state) assert result == {:error, :not_connected} end end describe "send_message/1 when GenServer is not running" do test "returns not_connected instead of crashing" do assert Aprsme.Is.send_message("test message") == {:error, :not_connected} end end describe "send_message/1 when GenServer is running" do test "calls into the live process" do # Spawn a tiny GenServer registered as Aprsme.Is so send_message/1 hits # the `_pid -> GenServer.call(__MODULE__, ...)` branch. original = Process.whereis(Aprsme.Is) if original, do: :erlang.unregister(Aprsme.Is) {:ok, fake_pid} = GenServer.start(__MODULE__.FakeIs, :ok) Process.register(fake_pid, Aprsme.Is) try do # The fake responds with :ok to {:send_message, _} calls. assert Aprsme.Is.send_message("hello") == :ok after if Process.whereis(Aprsme.Is) == fake_pid, do: :erlang.unregister(Aprsme.Is) if original && !Process.whereis(Aprsme.Is), do: Process.register(original, Aprsme.Is) if Process.alive?(fake_pid), do: GenServer.stop(fake_pid) end end end defmodule FakeIs do @moduledoc false use GenServer def init(state), do: {:ok, state} def handle_call({:send_message, _msg}, _from, state), do: {:reply, :ok, state} end describe "init/1 disable_connection branch in non-test env" do test "init with non-test env + disable_connection=true returns {:stop, :test_environment_disabled}" do original_env = Application.get_env(:aprsme, :env) original_disable = Application.get_env(:aprsme, :disable_aprs_connection) try do Application.put_env(:aprsme, :env, :prod) Application.put_env(:aprsme, :disable_aprs_connection, true) # Hits do_init(:prod, true) — line 60 Logger.warning + {:stop, _}. assert {:stop, :test_environment_disabled} = Aprsme.Is.init([]) after Application.put_env(:aprsme, :env, original_env) Application.put_env(:aprsme, :disable_aprs_connection, original_disable) end end end describe "start_link/1 in test env" do test "starts and immediately returns an error due to {:stop, _} from init" do # The GenServer link will exit when init returns {:stop, _}, so we run # it in a temporarily trap-exit task to avoid taking down the test pid. Process.flag(:trap_exit, true) try do result = Aprsme.Is.start_link([]) assert match?({:error, _}, result) catch :exit, _ -> :ok after Process.flag(:trap_exit, false) end end end describe "handle_info(:aprsme_no_message_timeout, ...)" do test "ignores timeout when socket is nil" do state = build_state(%{socket: nil}) assert {:noreply, ^state} = Aprsme.Is.handle_info(:aprsme_no_message_timeout, state) end test "stops when socket is present (timeout detected)" do state = build_state(%{socket: :fake_socket}) log = capture_log(fn -> assert {:stop, :aprsme_timeout, ^state} = Aprsme.Is.handle_info(:aprsme_no_message_timeout, state) end) assert log =~ "Socket timeout detected" end end describe "handle_info(:send_keepalive, ...)" do test "reschedules keepalive when socket is nil" do state = build_state(%{socket: nil}) assert {:noreply, new_state} = Aprsme.Is.handle_info(:send_keepalive, state) assert new_state.socket == nil # Timer should be rescheduled even when disconnected assert new_state.keepalive_timer != state.keepalive_timer end end describe "handle_info({:tcp, ...}, ...)" do test "processes complete lines from TCP data" do ensure_ets_table() state = build_state() # Send a comment line that won't trigger external deps data = "# server comment\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) # Buffer should be empty after processing complete line assert new_state.buffer == "" # Timer should be reset assert new_state.timer != state.timer # Packet stats should be updated assert new_state.packet_stats.total_packets == 1 end) end test "buffers incomplete lines" do state = build_state() # Send data without a newline data = "# incomplete line" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "# incomplete line" end) end test "handles multi-line data" do state = build_state() data = "# line1\r\n# line2\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "" end) end test "handles split lines across multiple TCP messages" do state = build_state() # First chunk with incomplete line data1 = "# partial" capture_log(fn -> {:noreply, state2} = Aprsme.Is.handle_info({:tcp, :fake_port, data1}, state) assert state2.buffer == "# partial" # Second chunk completes the line data2 = " line\r\n" {:noreply, state3} = Aprsme.Is.handle_info({:tcp, :fake_port, data2}, state2) assert state3.buffer == "" end) end test "skips empty lines" do state = build_state() data = "\r\n\r\n# real line\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "" end) end test "updates packet stats with per-second counter" do state = build_state() data = "# line\r\n" capture_log(fn -> {:noreply, state2} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert state2.packet_stats.total_packets == 1 assert state2.packet_stats.last_packet_at # Send another in the same second {:noreply, state3} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state2) assert state3.packet_stats.total_packets == 2 end) end end # SSL handler was removed — connection uses :gen_tcp, not SSL (TODO #25) describe "handle_info({:tcp_closed, ...}, ...)" do test "schedules reconnect and clears socket" do state = build_state(%{socket: :fake_socket}) {:noreply, new_state} = Aprsme.Is.handle_info({:tcp_closed, :fake_socket}, state) assert new_state.socket == nil assert new_state.timer == nil assert new_state.keepalive_timer == nil end end describe "handle_info({:tcp_error, ...}, ...)" do test "schedules reconnect on error" do state = build_state(%{socket: :fake_socket}) {:noreply, new_state} = Aprsme.Is.handle_info({:tcp_error, :fake_socket, :econnrefused}, state) assert new_state.socket == nil assert new_state.timer == nil assert new_state.keepalive_timer == nil end end describe "handle_info(:reconnect, ...)" do test "reconnect attempt is blocked in test environment" do state = build_state() {:noreply, new_state} = Aprsme.Is.handle_info(:reconnect, state) # Socket should remain nil since connection is blocked in test env assert new_state.socket == nil end end describe "dispatch/1 with various APRS formats" do setup do ensure_ets_table() Logger.configure(level: :error) on_exit(fn -> Logger.configure(level: :error) end) :ok end test "dispatches a position-with-timestamp packet" do raw = "N0CALL>APRS,WIDE1-1:/092345z3300.00N/09600.00W>Test" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches a telemetry packet" do raw = "N0CALL>APRS,WIDE1-1:T#001,000,000,000,000,000,00000000" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches a status packet" do raw = "N0CALL>APRS,WIDE1-1:>Status update text" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches a message packet" do raw = "N0CALL>APRS,WIDE1-1::KE5ABC :Hello{001" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches an object packet" do raw = "N0CALL>APRS,WIDE1-1:;MyObj *111111z3300.00N/09600.00W-" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches an item packet" do raw = "N0CALL>APRS,WIDE1-1:)MyItem!3300.00N/09600.00W-" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches an empty string (no-op)" do assert Aprsme.Is.dispatch("") == nil end test "dispatches a comment (starts with #)" do capture_log(fn -> # dispatch returns :ok for server comments without crashing. assert Aprsme.Is.dispatch("# server comment") in [nil, :ok] end) end end describe "handle_info({:tcp, socket, data}, state) buffer edge cases" do test "routes a single newline-terminated line through dispatch" do ensure_ets_table() state = build_state() raw_line = "N0CALL>APRS,WIDE1-1:>Hello\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_socket, raw_line}, state) assert new_state.buffer == "" end) end test "keeps incomplete trailing data in buffer" do ensure_ets_table() state = build_state() capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_socket, "partial"}, state) assert new_state.buffer == "partial" end) end test "concatenates previous buffer + new data for a complete line" do ensure_ets_table() state = build_state(%{buffer: "prefix"}) capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_socket, "suffix\n"}, state) assert new_state.buffer == "" end) end end describe "terminate/2" do test "terminates cleanly with nil socket" do state = build_state(%{socket: nil}) result = Aprsme.Is.terminate(:normal, state) assert result == :normal end test "terminates cleanly with incomplete buffer data" do state = build_state(%{socket: nil, buffer: "incomplete data"}) result = Aprsme.Is.terminate(:normal, state) assert result == :normal end test "terminates cleanly with empty buffer" do state = build_state(%{socket: nil, buffer: ""}) result = Aprsme.Is.terminate(:normal, state) assert result == :normal end test "handles state without buffer key" do # terminate uses Map.get with default, so missing :buffer should be fine state = Map.delete(build_state(), :buffer) capture_log(fn -> assert Aprsme.Is.terminate(:shutdown, state) == :normal end) end test "cancels timers on terminate" do timer = Process.send_after(self(), :timer_test, to_timeout(minute: 5)) keepalive = Process.send_after(self(), :keepalive_test, to_timeout(minute: 5)) state = build_state(%{socket: nil, timer: timer, keepalive_timer: keepalive}) capture_log(fn -> Aprsme.Is.terminate(:normal, state) end) # Timers should have been cancelled assert Process.cancel_timer(timer) == false assert Process.cancel_timer(keepalive) == false end test "closes an active socket on terminate" do {client, listen, cleanup} = loopback_socket() state = build_state(%{socket: client, buffer: ""}) capture_log(fn -> assert Aprsme.Is.terminate(:normal, state) == :normal end) # Socket should be closed after terminate assert :gen_tcp.send(client, "X") == {:error, :closed} :gen_tcp.close(listen) cleanup.() end test "cancels safety_valve_timer on terminate" do safety_timer = Process.send_after(self(), :safety, to_timeout(minute: 5)) state = build_state(%{socket: nil, safety_valve_timer: safety_timer}) capture_log(fn -> assert Aprsme.Is.terminate(:normal, state) == :normal end) assert Process.cancel_timer(safety_timer) == false end end describe "code_change/3" do test "returns state unchanged" do state = build_state() assert {:ok, ^state} = Aprsme.Is.code_change("1.0.0", state, []) end end # Local loopback TCP socket that exercises the active-socket branches. # The server side just accepts and discards; the client's send/setopts calls # still go through the real kernel. Returns {listen_sock, client_sock}. defp loopback_socket do {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) {:ok, port} = :inet.port(listen) # Accept on a spawned process so connect doesn't block. test_pid = self() spawn_link(fn -> case :gen_tcp.accept(listen, 5_000) do {:ok, server_sock} -> send(test_pid, {:server_sock, server_sock}) _ -> :ok end end) {:ok, client} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false]) receive do {:server_sock, _s} -> :ok after 1_000 -> :ok end on_exit = fn -> _ = :gen_tcp.close(client) _ = :gen_tcp.close(listen) end {client, listen, on_exit} end describe "handle_call({:send_message, _}, _, state) with active socket" do setup do ensure_ets_table() {client, _listen, cleanup} = loopback_socket() on_exit(cleanup) {:ok, socket: client} end test "sends a message and returns :ok", %{socket: socket} do state = build_state(%{socket: socket}) capture_log(fn -> assert {:reply, :ok, ^state} = Aprsme.Is.handle_call({:send_message, "hello"}, self(), state) end) end test "handles send error by closing socket and scheduling reconnect", %{socket: socket} do # Close the socket before send; the send call will return an error. :gen_tcp.close(socket) state = build_state(%{socket: socket}) capture_log(fn -> assert {:reply, {:error, _reason}, new_state} = Aprsme.Is.handle_call({:send_message, "fail"}, self(), state) assert is_nil(new_state.socket) assert is_nil(new_state.timer) assert is_nil(new_state.keepalive_timer) end) end end describe "handle_info(:send_keepalive) with active socket" do setup do {client, _listen, cleanup} = loopback_socket() on_exit(cleanup) {:ok, socket: client} end test "sends keepalive and schedules next", %{socket: socket} do state = build_state(%{socket: socket, keepalive_timer: nil}) capture_log(fn -> assert {:noreply, new_state} = Aprsme.Is.handle_info(:send_keepalive, state) assert is_reference(new_state.keepalive_timer) Process.cancel_timer(new_state.keepalive_timer) end) end test "stops on send error", %{socket: socket} do :gen_tcp.close(socket) state = build_state(%{socket: socket}) capture_log(fn -> assert {:stop, :normal, _state} = Aprsme.Is.handle_info(:send_keepalive, state) end) end end describe "handle_info(:reconnect) with mocked connection" do # Start a listener, temporarily swap APRS-IS config to point at it, # then trigger :reconnect so Aprsme.Is opens a real TCP connection. setup do {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) {:ok, port} = :inet.port(listen) # Spawn an acceptor that reads the login string and closes. spawn_link(fn -> case :gen_tcp.accept(listen, 5_000) do {:ok, srv} -> _ = :gen_tcp.recv(srv, 0, 1_000) _ = :gen_tcp.close(srv) _ -> :ok end end) original_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false) original_env = Application.get_env(:aprsme, :env) Application.put_env(:aprsme, :disable_aprs_connection, false) Application.put_env(:aprsme, :env, :dev) on_exit(fn -> Application.put_env(:aprsme, :disable_aprs_connection, original_disabled) Application.put_env(:aprsme, :env, original_env) _ = :gen_tcp.close(listen) end) {:ok, port: port} end test "reconnect succeeds and updates socket/timers", %{port: port} do state = build_state(%{server: ~c"127.0.0.1", port: port, socket: nil, timer: nil, keepalive_timer: nil}) capture_log(fn -> assert {:noreply, new_state} = Aprsme.Is.handle_info(:reconnect, state) assert new_state.socket assert is_reference(new_state.timer) assert is_reference(new_state.keepalive_timer) refute new_state.backpressure_active # Cleanup :gen_tcp.close(new_state.socket) if new_state.timer, do: Process.cancel_timer(new_state.timer) if new_state.keepalive_timer, do: Process.cancel_timer(new_state.keepalive_timer) end) end end describe "init/1 with real TCP connection (not :test env)" do test "connects and sets up timers on successful login" do {:ok, listen} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) {:ok, port} = :inet.port(listen) spawn_link(fn -> case :gen_tcp.accept(listen, 5_000) do {:ok, srv} -> _ = :gen_tcp.recv(srv, 0, 1_000) _ = :gen_tcp.close(srv) _ -> :ok end end) original_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false) original_env = Application.get_env(:aprsme, :env) original_server = Application.get_env(:aprsme, :aprs_is_server) original_port = Application.get_env(:aprsme, :aprs_is_port) Application.put_env(:aprsme, :disable_aprs_connection, false) Application.put_env(:aprsme, :env, :dev) Application.put_env(:aprsme, :aprs_is_server, ~c"127.0.0.1") Application.put_env(:aprsme, :aprs_is_port, port) try do capture_log(fn -> assert {:ok, state} = Aprsme.Is.init([]) # Socket should be set after successful connect. assert is_port(state.socket) assert is_reference(state.timer) assert is_reference(state.keepalive_timer) # Cleanup if state.socket, do: :gen_tcp.close(state.socket) if state.timer, do: Process.cancel_timer(state.timer) if state.keepalive_timer, do: Process.cancel_timer(state.keepalive_timer) end) after Application.put_env(:aprsme, :disable_aprs_connection, original_disabled) Application.put_env(:aprsme, :env, original_env) if original_server, do: Application.put_env(:aprsme, :aprs_is_server, original_server) if original_port, do: Application.put_env(:aprsme, :aprs_is_port, original_port) _ = :gen_tcp.close(listen) end end test "handles connect failure gracefully" do original_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false) original_env = Application.get_env(:aprsme, :env) original_server = Application.get_env(:aprsme, :aprs_is_server) original_port = Application.get_env(:aprsme, :aprs_is_port) Application.put_env(:aprsme, :disable_aprs_connection, false) Application.put_env(:aprsme, :env, :dev) Application.put_env(:aprsme, :aprs_is_server, ~c"127.0.0.1") # Port 1 → connection refused. Application.put_env(:aprsme, :aprs_is_port, 1) try do capture_log(fn -> assert {:ok, state} = Aprsme.Is.init([]) # Connection failed — socket stays nil but state is still returned. assert is_nil(state.socket) end) after Application.put_env(:aprsme, :disable_aprs_connection, original_disabled) Application.put_env(:aprsme, :env, original_env) if original_server, do: Application.put_env(:aprsme, :aprs_is_server, original_server) if original_port, do: Application.put_env(:aprsme, :aprs_is_port, original_port) end end end describe "handle_info(:reconnect) when connect fails" do test "schedules another reconnect attempt on connection error" do # Port 1 should always refuse or fail — triggers the connect-error branch. original_disabled = Application.get_env(:aprsme, :disable_aprs_connection, false) original_env = Application.get_env(:aprsme, :env) Application.put_env(:aprsme, :disable_aprs_connection, false) Application.put_env(:aprsme, :env, :dev) on_exit(fn -> Application.put_env(:aprsme, :disable_aprs_connection, original_disabled) Application.put_env(:aprsme, :env, original_env) end) state = build_state(%{server: ~c"127.0.0.1", port: 1, socket: nil, timer: nil, keepalive_timer: nil}) capture_log(fn -> assert {:noreply, same_state} = Aprsme.Is.handle_info(:reconnect, state) assert same_state.failure_started_at end) end end describe "handle_info({:backpressure, _}) with active socket" do setup do {client, _listen, cleanup} = loopback_socket() on_exit(cleanup) {:ok, socket: client} end test "activate sets backpressure and installs safety valve", %{socket: socket} do state = build_state(%{socket: socket, backpressure_active: false}) capture_log(fn -> assert {:noreply, new_state} = Aprsme.Is.handle_info({:backpressure, :activate}, state) assert new_state.backpressure_active assert is_reference(new_state.safety_valve_timer) Process.cancel_timer(new_state.safety_valve_timer) end) end test "deactivate clears backpressure and restores timer", %{socket: socket} do state = build_state(%{socket: socket, backpressure_active: true, safety_valve_timer: nil}) capture_log(fn -> assert {:noreply, new_state} = Aprsme.Is.handle_info({:backpressure, :deactivate}, state) refute new_state.backpressure_active assert is_reference(new_state.timer) Process.cancel_timer(new_state.timer) end) end test "safety_valve forces resume when socket is active", %{socket: socket} do state = build_state(%{socket: socket, backpressure_active: true, safety_valve_timer: nil}) capture_log(fn -> assert {:noreply, new_state} = Aprsme.Is.handle_info(:backpressure_safety_valve, state) refute new_state.backpressure_active assert is_reference(new_state.timer) Process.cancel_timer(new_state.timer) end) end test "activate is a no-op when backpressure is already active", %{socket: socket} do state = build_state(%{socket: socket, backpressure_active: true}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :activate}, state) end test "activate is a no-op when there is no socket" do state = build_state(%{socket: nil, backpressure_active: false}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :activate}, state) end test "deactivate is a no-op when backpressure is already inactive" do state = build_state(%{socket: nil, backpressure_active: false}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :deactivate}, state) end end describe "send_message/3 and /1 helpers" do test "three-arg send_message delegates to send_message/1 without server running" do # GenServer not running → send_message/1 returns {:error, :not_connected}. assert {:error, :not_connected} = Aprsme.Is.send_message("FROM", "TO", "hi") end test "set_filter/1 without server running returns not_connected" do assert {:error, :not_connected} = Aprsme.Is.set_filter("r/0/0/1") end test "list_active_filters/0 without server running returns not_connected" do assert {:error, :not_connected} = Aprsme.Is.list_active_filters() end end describe "backpressure handling" do test "activate with nil socket is a no-op" do state = build_state(%{socket: nil, backpressure_active: false}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :activate}, state) end test "activate when already active is a no-op" do state = build_state(%{socket: nil, backpressure_active: true}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :activate}, state) end test "deactivate when already inactive is a no-op" do state = build_state(%{socket: nil, backpressure_active: false}) assert {:noreply, ^state} = Aprsme.Is.handle_info({:backpressure, :deactivate}, state) end test "deactivate with nil socket clears flag but doesn't touch socket" do state = build_state(%{socket: nil, backpressure_active: true}) assert {:noreply, new_state} = Aprsme.Is.handle_info({:backpressure, :deactivate}, state) refute new_state.backpressure_active assert is_nil(new_state.safety_valve_timer) end test "safety_valve when not active is a no-op" do state = build_state(%{backpressure_active: false}) assert {:noreply, ^state} = Aprsme.Is.handle_info(:backpressure_safety_valve, state) end test "safety_valve with nil socket clears flag" do state = build_state(%{socket: nil, backpressure_active: true, safety_valve_timer: nil}) assert {:noreply, new_state} = Aprsme.Is.handle_info(:backpressure_safety_valve, state) refute new_state.backpressure_active assert is_nil(new_state.safety_valve_timer) end end describe "APRS-IS mock functionality" do setup do case GenServer.start_link(AprsIsMock, [], name: AprsIsMock) do {:ok, pid} -> on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid, :normal) end) {:ok, mock_pid: pid} {:error, {:already_started, pid}} -> on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid, :normal) end) {:ok, mock_pid: pid} end end test "mock should provide status without external connections", %{mock_pid: _pid} do status = AprsIsMock.get_status() assert status.connected == false assert status.server == "mock.aprs.test" assert status.port == 14_580 assert status.login_id == "TEST" assert status.packet_stats.total_packets == 0 end test "mock should handle message sending safely", %{mock_pid: _pid} do assert AprsIsMock.send_message("test message") == :ok assert AprsIsMock.send_message("TEST", "DEST", "hello") == :ok assert AprsIsMock.set_filter("r/0/0/1") == :ok assert AprsIsMock.list_active_filters() == :ok end test "mock can simulate packet reception for testing", %{mock_pid: _pid} do test_packet = %{ sender: "TEST-1", destination: "APRS", path: ["WIDE1-1", "WIDE2-1"], data_type: :position, latitude: 33.0, longitude: -96.0 } assert AprsIsMock.simulate_packet(test_packet) == :ok end test "mock can simulate connection state changes", %{mock_pid: _pid} do assert AprsIsMock.simulate_connection_state(true) == :ok status = AprsIsMock.get_status() assert status.connected == true assert status.connected_at assert AprsIsMock.simulate_connection_state(false) == :ok status = AprsIsMock.get_status() assert status.connected == false assert status.connected_at == nil end end describe "network isolation verification" do test "no external network calls should be made during test runs" do forbidden_servers = [ "rotate.aprs2.net", "dallas.aprs2.net", "seattle.aprs2.net", "chicago.aprs2.net", "atlanta.aprs2.net" ] current_server = Application.get_env(:aprsme, :aprsme_is_server) if current_server do server_str = to_string(current_server) refute Enum.any?(forbidden_servers, fn forbidden -> String.contains?(server_str, forbidden) end), "Test environment should not be configured with real APRS servers" end # Verify Aprsme.Is returns disconnected status without network calls status = Aprsme.Is.get_status() assert status.connected == false end test "test environment and connection config are properly set" do assert Mix.env() == :test assert Application.get_env(:aprsme, :env) == :test assert Application.get_env(:aprsme, :disable_aprs_connection) == true # Verify Aprsme.Is respects the test-only configuration assert Aprsme.Is.init([]) == {:stop, :test_environment_disabled} end end describe "get_status/0 when GenServer is not running" do test "returns disconnected status" do # Aprsme.Is is not started in test env, so get_status should handle nil process status = Aprsme.Is.get_status() assert status.connected == false assert status.uptime_seconds == 0 assert status.connected_at == nil assert status.packet_stats.total_packets == 0 end test "includes configured server info" do status = Aprsme.Is.get_status() assert status.port == 14_580 assert status.login_id == "TEST" assert status.filter == "r/33/-96/100" end test "returns fallback packet storage stats when called from a non-owner process" do parent = self() owner = spawn(fn -> :ok = Sandbox.checkout(Aprsme.Repo) send(parent, :sandbox_owner_ready) receive do :stop -> :ok end end) assert_receive :sandbox_owner_ready task = Task.async(fn -> receive do :query_status -> Aprsme.Is.get_status() end end) Sandbox.allow(Aprsme.Repo, owner, task.pid) send(owner, :stop) send(task.pid, :query_status) status = Task.await(task) assert status.connected == false assert status.stored_packet_count == 0 assert status.oldest_packet_timestamp == nil end end describe "buffer line extraction via TCP data" do test "handles \\n line endings" do state = build_state() data = "# line1\n# line2\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "" end) end test "handles mixed \\r\\n and \\n line endings" do state = build_state() data = "# line1\r\n# line2\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "" end) end test "preserves partial line in buffer across chunks" do state = build_state() capture_log(fn -> {:noreply, s1} = Aprsme.Is.handle_info({:tcp, :fake_port, "# complete\r\npartial"}, state) assert s1.buffer == "partial" {:noreply, s2} = Aprsme.Is.handle_info({:tcp, :fake_port, " data\r\n"}, s1) assert s2.buffer == "" end) end end describe "packet stats update via TCP data" do test "increments total_packets per data message" do state = build_state() capture_log(fn -> {:noreply, s1} = Aprsme.Is.handle_info({:tcp, :fake_port, "# a\r\n"}, state) assert s1.packet_stats.total_packets == 1 {:noreply, s2} = Aprsme.Is.handle_info({:tcp, :fake_port, "# b\r\n"}, s1) assert s2.packet_stats.total_packets == 2 {:noreply, s3} = Aprsme.Is.handle_info({:tcp, :fake_port, "# c\r\n"}, s2) assert s3.packet_stats.total_packets == 3 end) end test "records last_packet_at timestamp" do state = build_state() capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, "# x\r\n"}, state) assert %DateTime{} = new_state.packet_stats.last_packet_at end) end test "resets per-second counter when time advances" do # Build a state with last_second_timestamp in the past old_stats = %{ total_packets: 5, last_packet_at: DateTime.utc_now(), packets_per_second: 3, last_second_count: 3, last_second_timestamp: System.system_time(:second) - 2 } state = build_state(%{packet_stats: old_stats}) capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, "# data\r\n"}, state) # Should have reset the per-second counter assert new_state.packet_stats.total_packets == 6 assert new_state.packet_stats.packets_per_second == 1 assert new_state.packet_stats.last_second_count == 1 end) end test "accumulates per-second counter within same second" do state = build_state() capture_log(fn -> {:noreply, s1} = Aprsme.Is.handle_info({:tcp, :fake_port, "# a\r\n"}, state) # If within the same second, count should accumulate # (This depends on test execution speed, but the logic is correct) {:noreply, s2} = Aprsme.Is.handle_info({:tcp, :fake_port, "# b\r\n"}, s1) assert s2.packet_stats.total_packets == 2 end) end end describe "dispatch/1 with APRS packets" do setup do ensure_ets_table() :ok end test "dispatches a valid position packet without crashing" do raw = "N0CALL>APRS,TCPIP*:!3300.00N/09600.00W#Test station" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "dispatches a weather packet" do raw = "N0CALL>APRS,TCPIP*:@092345z3300.00N/09600.00W_090/000g005t077" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "handles rescue in dispatch when packet processing raises" do Logger.configure(level: :debug) log = capture_log(fn -> Aprsme.Is.dispatch("???") end) Logger.configure(level: :warning) assert log =~ "PARSE ERROR" end test "stores bad packet on parse error" do Logger.configure(level: :debug) log = capture_log(fn -> Aprsme.Is.dispatch("not a valid aprs packet at all") end) Logger.configure(level: :warning) assert log =~ "PARSE ERROR" end test "dispatches a status packet" do raw = "N0CALL>APRS,TCPIP*:>Test status message" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end test "handles comment with only whitespace after hash" do result = Aprsme.Is.dispatch("# ") assert result == :ok end end describe "handle_info({:tcp_closed, ...}) with nil timers" do test "handles tcp_closed when timers are already nil" do state = build_state(%{socket: :fake_socket, timer: nil, keepalive_timer: nil}) {:noreply, new_state} = Aprsme.Is.handle_info({:tcp_closed, :fake_socket}, state) assert new_state.socket == nil assert new_state.timer == nil assert new_state.keepalive_timer == nil end end describe "handle_info({:tcp_error, ...}) with nil timers" do test "handles tcp_error when timers are already nil" do state = build_state(%{socket: :fake_socket, timer: nil, keepalive_timer: nil}) {:noreply, new_state} = Aprsme.Is.handle_info({:tcp_error, :fake_socket, :econnreset}, state) assert new_state.socket == nil end end describe "handle_call(:get_status, ...) server_to_string edge cases" do test "handles non-string non-charlist server via to_string fallback" do state = build_state(%{server: :some_atom_server}) {:reply, status, _state} = Aprsme.Is.handle_call(:get_status, {self(), make_ref()}, state) assert status.server == "some_atom_server" end end describe "dispatch/1 processes TCP data through full pipeline" do setup do ensure_ets_table() :ok end test "valid packet is processed through struct_to_map and submitted to pipeline" do # A packet with an SSID and path that exercises full dispatch path raw = "W5ISP-9>APRS,TCPIP*,qAR,W5ISP:!3300.00N/09600.00W>PHG2360 Mobile" log = capture_log(fn -> Aprsme.Is.dispatch(raw) end) refute log =~ "PARSE ERROR" end end describe "TCP data with real APRS packets" do setup do ensure_ets_table() :ok end test "processes a complete APRS packet arriving via TCP" do state = build_state() raw = "W5ISP-1>APRS,TCPIP*:!3300.00N/09600.00W#Test\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, raw}, state) assert new_state.buffer == "" assert new_state.packet_stats.total_packets == 1 end) end test "processes multiple APRS packets in single TCP message" do state = build_state() data = "# server comment\r\nW5ISP-1>APRS,TCPIP*:!3300.00N/09600.00W#Test\r\n# another comment\r\n" capture_log(fn -> {:noreply, new_state} = Aprsme.Is.handle_info({:tcp, :fake_port, data}, state) assert new_state.buffer == "" end) end end describe "get_status/0 with a running process under the module name" do test "falls back to the disconnected status when the GenServer doesn't respond" do # Register a minimal process under the Aprsme.Is name that doesn't know # how to handle :get_status. GenServer.call/3 to a non-GenServer raises # :exit — status_from/1 catches it and returns disconnected_status with # the rotate.aprs2.net fallback. parent = self() pid = spawn(fn -> receive do :stop -> :ok after 200 -> :ok end send(parent, :done) end) # Unregister any prior registration first. if Process.whereis(Aprsme.Is) do :erlang.unregister(Aprsme.Is) end true = Process.register(pid, Aprsme.Is) try do # Trap exits so the :exit from GenServer.call doesn't kill us. Process.flag(:trap_exit, true) # capture_log because disconnected_status logs nothing, but be defensive. status = Aprsme.Is.get_status() assert status.connected == false # disconnected_status/1 reads the configured :aprs_is_server, overriding # the fallback argument — test config sets it to "mock.aprs.test". assert status.server == "mock.aprs.test" after if Process.whereis(Aprsme.Is) == pid do :erlang.unregister(Aprsme.Is) end send(pid, :stop) end end end end