defmodule Aprsme.PacketsTest do use Aprsme.DataCase, async: true alias Aprs.Types.MicE alias Aprsme.BadPacket alias Aprsme.Packet alias Aprsme.Packets alias Aprsme.PacketsFixtures describe "store_packet/1" do test "stores a valid packet" do packet_data = %{ sender: "TEST1", base_callsign: "TEST1", ssid: "0", destination: "APRS", path: "WIDE1-1", raw_packet: "TEST1>APRS,WIDE1-1:=3950.00N/09830.00W>Test packet", lat: 39.833333, lon: -98.5, comment: "Test packet", data_type: "position_without_timestamp_with_messaging", symbol_table_id: "/", symbol_code: ">" } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "TEST1" # Handle the fact that lat/lon may be stored as Decimal lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon assert Float.round(lat_value, 4) == 39.8333 assert Float.round(lon_value, 1) == -98.5 assert packet.comment == "Test packet" end test "stores packet with data_extended containing position" do packet_data = %{ sender: "TEST2", base_callsign: "TEST2", ssid: "0", destination: "APRS", raw_packet: "TEST2>APRS:!3950.00N/09830.00W>", data_type: "position", data_extended: %{ latitude: 39.833333, longitude: -98.5, symbol_table_id: "/", symbol_code: ">" } } assert {:ok, packet} = Packets.store_packet(packet_data) lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon assert Float.round(lat_value, 4) == 39.8333 assert Float.round(lon_value, 1) == -98.5 end test "stores packet with MicE data" do packet_data = %{ sender: "TEST3", base_callsign: "TEST3", ssid: "0", destination: "APRS", path: "", raw_packet: "TEST3>APRS:`123abc", data_type: "mic_e", data_extended: %{ __struct__: MicE, lat_degrees: 39, lat_minutes: 50, lat_direction: :north, lon_degrees: 98, lon_minutes: 30, lon_direction: :west } } assert {:ok, packet} = Packets.store_packet(packet_data) # MicE calculation: 39 + 50/60 = 39.833333 lat_value = if is_struct(packet.lat, Decimal), do: Decimal.to_float(packet.lat), else: packet.lat lon_value = if is_struct(packet.lon, Decimal), do: Decimal.to_float(packet.lon), else: packet.lon assert Float.round(lat_value, 4) == 39.8333 assert Float.round(lon_value, 1) == -98.5 end test "handles validation errors gracefully" do # Missing required fields packet_data = %{ raw_packet: "INVALID>PACKET" } assert {:error, :validation_error} = Packets.store_packet(packet_data) # Verify bad packet was stored assert [bad_packet] = Repo.all(BadPacket) assert bad_packet.raw_packet == "INVALID>PACKET" assert bad_packet.error_type == "ValidationError" end test "handles invalid data types gracefully" do # Create invalid data with wrong type for data_extended packet_data = %{ sender: "TEST4", destination: "APRS", raw_packet: "TEST4>APRS:Invalid", # Invalid: data_extended should be a map, not a string data_extended: "invalid_data_extended" } # Should return validation error due to missing required fields assert {:error, :validation_error} = Packets.store_packet(packet_data) end test "normalizes SSID to string" do packet_data = %{ sender: "TEST5", base_callsign: "TEST5", ssid: 9, destination: "APRS", raw_packet: "TEST5-9>APRS:=3950.00N/09830.00W>", data_type: "position", lat: 39.833333, lon: -98.5 } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.ssid == "9" end test "sets received_at timestamp" do packet_data = %{ sender: "TEST6", base_callsign: "TEST6", ssid: "0", destination: "APRS", raw_packet: "TEST6>APRS:=3950.00N/09830.00W>", data_type: "position", lat: 39.833333, lon: -98.5 } assert {:ok, packet} = Packets.store_packet(packet_data) # Just verify that received_at was set assert packet.received_at assert %DateTime{} = packet.received_at end test "sanitizes binary data in packets" do packet_data = %{ sender: "TEST7", base_callsign: "TEST7", ssid: "0", destination: "APRS", raw_packet: "TEST7>APRS:Test with null byte", comment: "Comment with null", data_type: "comment", lat: 39.833333, lon: -98.5 } assert {:ok, packet} = Packets.store_packet(packet_data) # Verify the packet was stored successfully assert packet.sender == "TEST7" end end describe "store_packet/1 with additional edge cases" do test "stores a packet with string-keyed data_extended" do packet_data = %{ sender: "STRKEY1", base_callsign: "STRKEY1", ssid: "0", destination: "APRS", raw_packet: "STRKEY1>APRS", data_type: "position", data_extended: %{ "latitude" => 33.0, "longitude" => -96.0, "symbol_table_id" => "/", "symbol_code" => ">" } } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "STRKEY1" end test "stores a packet with nested position map inside data_extended" do packet_data = %{ sender: "NESTPOS1", base_callsign: "NESTPOS1", ssid: "0", destination: "APRS", raw_packet: "NESTPOS1>APRS", data_type: "position", data_extended: %{ position: %{latitude: 33.0, longitude: -96.0} } } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "NESTPOS1" end test "device_identifier is set from destination fallback when DeviceParser returns nil" do packet_data = %{ sender: "DEVFALL1", base_callsign: "DEVFALL1", ssid: "0", destination: "APUNKNOWN", raw_packet: "DEVFALL1>APUNKNOWN:=3950.00N/09830.00W>", data_type: "position", lat: 39.83, lon: -98.5 } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.device_identifier == "APUNKNOWN" end test "handles binary position from data_extended" do packet_data = %{ sender: "STRPOS1", base_callsign: "STRPOS1", ssid: "0", destination: "APRS", raw_packet: "STRPOS1>APRS", data_type: "position", data_extended: %{ latitude: "33.0", longitude: "-96.5" } } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "STRPOS1" end test "handles binary position with non-parseable decimal" do packet_data = %{ sender: "BADPOS1", base_callsign: "BADPOS1", ssid: "0", destination: "APRS", raw_packet: "BADPOS1>APRS", data_type: "position", data_extended: %{ latitude: "invalid", longitude: "-96.5" } } # Without parseable lat, extract_position returns {nil, nil} — packet stores # but without position data. assert {:ok, _packet} = Packets.store_packet(packet_data) end test "supports integer lat/lon that gets coerced to float" do packet_data = %{ sender: "INTCOORD1", base_callsign: "INTCOORD1", ssid: "0", destination: "APRS", raw_packet: "INTCOORD1>APRS", data_type: "position", lat: 33, lon: -96 } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "INTCOORD1" end test "handles a binary lat/lon that can be parsed" do packet_data = %{ sender: "BINCOORD1", base_callsign: "BINCOORD1", ssid: "0", destination: "APRS", raw_packet: "BINCOORD1>APRS", data_type: "position", lat: "39.833", lon: "-98.5" } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "BINCOORD1" end end describe "store_bad_packet/2 error shape coverage" do test "stores bad packet from string with struct error" do # An exception struct — should hit the struct-type branch for error_type. error = %ArgumentError{message: "bad arg"} assert {:ok, bad_packet} = Packets.store_bad_packet("INVALID", error) assert bad_packet.raw_packet == "INVALID" assert bad_packet.error_type == "ArgumentError" assert bad_packet.error_message =~ "bad arg" end test "stores bad packet from string with unstructured error" do # An error that's neither a map nor a struct — falls to UnknownError. assert {:ok, bad_packet} = Packets.store_bad_packet("INVALID", :some_atom) assert bad_packet.error_type == "UnknownError" assert bad_packet.error_message == ":some_atom" end test "stores bad packet from map with struct error" do error = %RuntimeError{message: "boom"} assert {:ok, bad_packet} = Packets.store_bad_packet(%{raw_packet: "MAP-STRUCT"}, error) assert bad_packet.raw_packet == "MAP-STRUCT" assert bad_packet.error_type == "RuntimeError" end test "stores bad packet from map with unstructured error" do assert {:ok, bad_packet} = Packets.store_bad_packet(%{raw_packet: "MAP-UNK"}, :unknown) assert bad_packet.error_type == "UnknownError" end end describe "round_coordinate via store_packet for various coord types" do test "stores Decimal-typed lat/lon" do packet = %{ sender: "DEC-LL", base_callsign: "DEC-LL", ssid: "0", destination: "APRS", raw_packet: "DEC-LL>APRS", data_type: "position", lat: Decimal.new("33.0"), lon: Decimal.new("-96.5") } assert {:ok, _packet} = Packets.store_packet(packet) end test "stores integer-typed lat/lon" do packet = %{ sender: "INT-LL", base_callsign: "INT-LL", ssid: "0", destination: "APRS", raw_packet: "INT-LL>APRS", data_type: "position", lat: 33, lon: -96 } assert {:ok, _packet} = Packets.store_packet(packet) end test "stores binary-string-typed lat/lon (parses via Float.parse)" do packet = %{ sender: "STR-LL", base_callsign: "STR-LL", ssid: "0", destination: "APRS", raw_packet: "STR-LL>APRS", data_type: "position", lat: "33.0", lon: "-96.5" } assert {:ok, _packet} = Packets.store_packet(packet) end end describe "extract_position_from_mic_e via store_packet with full MicE map keys" do test "stores a packet whose data_extended is a fully-keyed MicE map" do packet = %{ sender: "MICE-FULL", base_callsign: "MICE-FULL", ssid: "0", destination: "APRS", raw_packet: "MICE-FULL>APRS", data_type: "mic_e", data_extended: %{ lat_degrees: 33, lat_minutes: 30.0, lat_direction: :north, lon_degrees: 96, lon_minutes: 30.0, lon_direction: :west } } assert {:ok, _packet} = Packets.store_packet(packet) end test "south/west directions go negative via apply_direction" do packet = %{ sender: "MICE-SW", base_callsign: "MICE-SW", ssid: "0", destination: "APRS", raw_packet: "MICE-SW>APRS", data_type: "mic_e", data_extended: %{ lat_degrees: 33, lat_minutes: 0.0, lat_direction: :south, lon_degrees: 96, lon_minutes: 0.0, lon_direction: :east } } assert {:ok, _packet} = Packets.store_packet(packet) end end describe "store_packet/1 with nested position shapes" do test "extracts coordinates from data_extended.position with atom keys" do # Hits find_coordinate_value(%{position: %{latitude: lat}}, :latitude) packet_data = %{ sender: "POSATOM1", base_callsign: "POSATOM1", ssid: "0", destination: "APRS", raw_packet: "POSATOM1>APRS", data_type: "position", data_extended: %{position: %{latitude: 33.0, longitude: -96.0}} } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "POSATOM1" end test "extracts coordinates from data_extended.position with string keys" do # Hits find_coordinate_value(%{position: %{"latitude" => lat}}, :latitude) packet_data = %{ sender: "POSSTR1", base_callsign: "POSSTR1", ssid: "0", destination: "APRS", raw_packet: "POSSTR1>APRS", data_type: "position", data_extended: %{position: %{"latitude" => 33.0, "longitude" => -96.0}} } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "POSSTR1" end test "extracts coordinates from string-keyed position" do # Hits find_coordinate_value(%{"position" => %{...}}, "latitude") variants packet_data = %{ sender: "POSSTRKEY1", base_callsign: "POSSTRKEY1", ssid: "0", destination: "APRS", raw_packet: "POSSTRKEY1>APRS", data_type: "position", data_extended: %{"position" => %{"latitude" => 33.0, "longitude" => -96.0}} } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.sender == "POSSTRKEY1" end end describe "store_packet/1 ssid handling" do test "exercises normalize_ssid nil branch even when changeset later rejects" do # normalize_ssid(%{ssid: nil} = attrs), do: attrs — line 219 branch. # We don't care if the final changeset accepts it; we care that the # private normalize_ssid clause for nil is reached. packet_data = %{ sender: "NILSSID1", base_callsign: "NILSSID1", ssid: nil, destination: "APRS", raw_packet: "NILSSID1>APRS", data_type: "position", lat: 33.0, lon: -96.0 } # Validation rejects empty ssid; that's fine — our goal here is exercising # the nil-ssid branch upstream of the changeset. assert {:error, :validation_error} = Packets.store_packet(packet_data) end test "stores packet with integer ssid (gets stringified)" do # normalize_ssid(%{ssid: ssid} = attrs), do: Map.put(attrs, :ssid, to_string(ssid)) packet_data = %{ sender: "INTSSID1", base_callsign: "INTSSID1", ssid: 9, destination: "APRS", raw_packet: "INTSSID1>APRS", data_type: "position", lat: 33.0, lon: -96.0 } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.ssid == "9" end end describe "store_packet/1 with string-keyed raw_packet" do test "extracts raw_packet from a fully string-keyed map" do # get_raw_packet(%{"raw_packet" => raw}) when is_binary(raw) — line 79. # Ecto changesets reject mixed-key maps, so build the map entirely with # string keys to exercise the string-key clause without tripping CastError. packet_data = %{ "raw_packet" => "STRRAW1>APRS:test packet", "sender" => "STRRAW1", "base_callsign" => "STRRAW1", "ssid" => "0", "destination" => "APRS", "data_type" => "position", "lat" => 33.0, "lon" => -96.0 } # Ecto changesets reject mixed-key maps — we only care that the # get_raw_packet string-key clause was exercised without crashing. assert {:error, :storage_exception} = Packets.store_packet(packet_data) end end describe "store_packet/1 with MicE struct position extraction" do test "extracts position from a MicE struct with numeric lat/lon" do packet_data = %{ sender: "MICESTRUCT", base_callsign: "MICESTRUCT", ssid: "0", destination: "APRS", raw_packet: "MICESTRUCT>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) assert packet.sender == "MICESTRUCT" end test "handles a ParseError struct in data_extended gracefully" do packet_data = %{ sender: "PARSEERR", base_callsign: "PARSEERR", ssid: "0", destination: "APRS", raw_packet: "PARSEERR>APRS", data_type: "position", data_extended: struct(Aprs.Types.ParseError) } # ParseError branch should return {nil, nil} for position — packet stores # without coordinate data. # Should succeed storing without position data or fail validation assert {:ok, _} = Packets.store_packet(packet_data) 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 test "keyset cursor excludes rows at/before cursor when paginating get_recent_packets" do now = DateTime.truncate(DateTime.utc_now(), :microsecond) older = DateTime.add(now, -3600, :second) _newer_pk = PacketsFixtures.packet_fixture(%{ sender: "CURSORA", base_callsign: "CURSORA", lat: 33.0, lon: -96.0, received_at: now, has_position: true }) older_pk = PacketsFixtures.packet_fixture(%{ sender: "CURSORB", base_callsign: "CURSORB", lat: 33.0, lon: -96.0, received_at: older, has_position: true }) # Use a cursor pointing to the older packet's received_at/id — only rows # strictly older should be returned, so we expect the set NOT to include # the cursor row itself. cursor = %{received_at: older_pk.received_at, id: older_pk.id} results = Packets.get_recent_packets(%{hours_back: 2, cursor: cursor, limit: 50}) senders = Enum.map(results, & &1.sender) refute "CURSORA" in senders refute "CURSORB" in senders end test "integer SSID is coerced to string via normalize_ssid" do # Covers the `defp normalize_ssid(%{ssid: ssid} = attrs), do: ...` clause # that triggers when ssid is a non-binary, non-nil value. packet_data = %{ sender: "SSIDI-5", base_callsign: "SSIDI", ssid: 5, destination: "APRS", raw_packet: "SSIDI-5>APRS:!3300.00N/09600.00W#", data_type: "position", data_extended: %{latitude: 33.0, longitude: -96.0} } assert {:ok, packet} = Packets.store_packet(packet_data) assert packet.ssid == "5" end end describe "store_packet/1 error paths" do test "storage exception path stores a bad packet" do # Send data that causes changeset insertion to fail at the DB level. # raw_packet too long exceeds any reasonable schema constraint, or we # can force a validation error via missing required fields. packet_data = %{ sender: "", raw_packet: "BAD" } result = Packets.store_packet(packet_data) # Should return a validation error for missing required fields. assert result == {:error, :validation_error} end end describe "store_bad_packet/2" do test "stores bad packet from string" do error = %{message: "Parse error", type: "ParseError"} assert {:ok, bad_packet} = Packets.store_bad_packet("INVALID>PACKET", error) assert bad_packet.raw_packet == "INVALID>PACKET" assert bad_packet.error_message == "Parse error" assert bad_packet.error_type == "ParseError" end test "stores bad packet from map" do packet_data = %{ raw_packet: "TEST>APRS:Invalid", sender: "TEST" } error = %{message: "Validation failed", type: "ValidationError"} assert {:ok, bad_packet} = Packets.store_bad_packet(packet_data, error) assert bad_packet.raw_packet == "TEST>APRS:Invalid" assert bad_packet.error_message == "Validation failed" end test "handles exception objects" do packet_data = %{raw_packet: "TEST>APRS:Invalid"} error = %RuntimeError{message: "Something went wrong"} assert {:ok, bad_packet} = Packets.store_bad_packet(packet_data, error) assert bad_packet.error_type == "RuntimeError" assert bad_packet.error_message == "Something went wrong" end end describe "get_recent_packets/1" do setup do # Create packets with different timestamps now = DateTime.utc_now() packets = [ PacketsFixtures.packet_fixture(%{ sender: "OLD1", received_at: DateTime.add(now, -48 * 3600, :second), lat: 39.8, lon: -98.5 }), PacketsFixtures.packet_fixture(%{ sender: "RECENT1", received_at: DateTime.add(now, -12 * 3600, :second), lat: 39.9, lon: -98.4 }), PacketsFixtures.packet_fixture(%{ sender: "RECENT2", received_at: DateTime.add(now, -6 * 3600, :second), lat: 40.0, lon: -98.3 }), PacketsFixtures.packet_fixture(%{ sender: "NOW1", received_at: now, lat: 40.1, lon: -98.2 }) ] {:ok, packets: packets} end test "returns packets from last 24 hours by default", %{packets: _packets} do results = Packets.get_recent_packets() # Should include RECENT1, RECENT2, and NOW1, but not OLD1 callsigns = Enum.map(results, & &1.sender) assert "RECENT1" in callsigns assert "RECENT2" in callsigns assert "NOW1" in callsigns refute "OLD1" in callsigns end test "respects hours_back parameter", %{packets: _packets} do results = Packets.get_recent_packets(%{hours_back: 8}) # Should only include RECENT2 and NOW1 callsigns = Enum.map(results, & &1.sender) refute "RECENT1" in callsigns assert "RECENT2" in callsigns assert "NOW1" in callsigns end test "respects limit parameter", %{packets: _packets} do results = Packets.get_recent_packets(%{limit: 2}) assert length(results) == 2 end test "filters by bounds" do # Create packets inside and outside bounds PacketsFixtures.packet_fixture(%{ sender: "INSIDE_BOUNDS", lat: 39.0, lon: -98.0, received_at: DateTime.utc_now() }) PacketsFixtures.packet_fixture(%{ sender: "OUTSIDE_BOUNDS", lat: 41.0, lon: -100.0, received_at: DateTime.utc_now() }) # Bounds format: [west, south, east, north] bounds = [-99.0, 38.0, -97.0, 40.0] results = Packets.get_recent_packets(%{bounds: bounds}) callsigns = Enum.map(results, & &1.sender) assert "INSIDE_BOUNDS" in callsigns refute "OUTSIDE_BOUNDS" in callsigns end test "filters by callsign" do PacketsFixtures.packet_fixture(%{ sender: "TARGET", lat: 39.0, lon: -98.0 }) PacketsFixtures.packet_fixture(%{ sender: "OTHER", lat: 39.1, lon: -98.1 }) results = Packets.get_recent_packets(%{callsign: "TARGET"}) assert length(results) == 1 assert hd(results).sender == "TARGET" end end describe "get_recent_packets_for_map/1" do setup do now = DateTime.utc_now() PacketsFixtures.packet_fixture(%{ sender: "MAP1", received_at: DateTime.add(now, -1800, :second), lat: 39.8, lon: -98.5, comment: "test comment", symbol_table_id: "/", symbol_code: ">", temperature: 72.0, humidity: 45.0 }) PacketsFixtures.packet_fixture(%{ sender: "MAP2", received_at: DateTime.add(now, -3600, :second), lat: 39.9, lon: -98.4, object_name: "TestObj" }) PacketsFixtures.packet_fixture(%{ sender: "OLD_MAP", received_at: DateTime.add(now, -48 * 3600, :second), lat: 40.0, lon: -98.3 }) :ok end test "returns maps with only needed fields" do results = Packets.get_recent_packets_for_map(%{hours_back: 24}) assert length(results) >= 2 first = hd(results) # Should be a plain map (not a Packet struct — maps don't have __meta__) refute Map.has_key?(first, :__meta__) # Should have the essential fields (single pattern match) assert %{ id: _, sender: _, lat: _, lon: _, received_at: _, symbol_table_id: _, symbol_code: _, comment: _, path: _, object_name: _, item_name: _, temperature: _, humidity: _ } = first # Should NOT have heavy fields refute Map.has_key?(first, :raw_packet) refute Map.has_key?(first, :information_field) refute Map.has_key?(first, :body) refute Map.has_key?(first, :data_extended) end test "respects time filtering" do results = Packets.get_recent_packets_for_map(%{hours_back: 1}) callsigns = Enum.map(results, & &1.sender) assert "MAP1" in callsigns refute "OLD_MAP" in callsigns end test "respects bounds filtering" do bounds = [-99.0, 39.5, -98.0, 40.5] results = Packets.get_recent_packets_for_map(%{hours_back: 24, bounds: bounds}) callsigns = Enum.map(results, & &1.sender) assert "MAP1" in callsigns refute "OLD_MAP" in callsigns end test "respects limit and offset" do results = Packets.get_recent_packets_for_map(%{hours_back: 24, limit: 1}) assert length(results) == 1 end test "filters by callsign" do results = Packets.get_recent_packets_for_map(%{hours_back: 24, callsign: "MAP1"}) assert length(results) == 1 assert hd(results).sender == "MAP1" end test "includes object_name for APRS objects" do results = Packets.get_recent_packets_for_map(%{hours_back: 24}) map2 = Enum.find(results, &(&1.sender == "MAP2")) assert map2.object_name == "TestObj" end end describe "get_nearby_stations/4" do setup do # Create stations at various distances now = DateTime.utc_now() stations = [ PacketsFixtures.packet_fixture(%{ sender: "NEAR1", base_callsign: "NEAR1", lat: 39.01, lon: -98.01, received_at: now }), PacketsFixtures.packet_fixture(%{ sender: "NEAR2", base_callsign: "NEAR2", lat: 39.02, lon: -98.02, received_at: now }), PacketsFixtures.packet_fixture(%{ sender: "FAR1", base_callsign: "FAR1", lat: 40.0, lon: -99.0, received_at: now }) ] {:ok, stations: stations} end test "returns stations ordered by distance", %{stations: _stations} do results = Packets.get_nearby_stations(39.0, -98.0) assert results != [] # Results should be ordered by distance [first | _] = results assert first.sender in ["NEAR1", "NEAR2"] end test "excludes specified callsign", %{stations: _stations} do results = Packets.get_nearby_stations(39.0, -98.0, "NEAR1") callsigns = Enum.map(results, & &1.sender) refute "NEAR1" in callsigns assert "NEAR2" in callsigns end test "respects limit option" do results = Packets.get_nearby_stations(39.0, -98.0, nil, %{limit: 1}) assert length(results) == 1 end end describe "get_weather_packets/4" do setup do now = DateTime.utc_now() start_time = DateTime.add(now, -24 * 3600, :second) end_time = now # Create weather packets weather_packet1 = PacketsFixtures.packet_fixture(%{ sender: "WX1", temperature: 72.5, humidity: 65, wind_speed: 5, received_at: DateTime.add(now, -12 * 3600, :second) }) weather_packet2 = PacketsFixtures.packet_fixture(%{ sender: "WX1", temperature: 73.0, humidity: 60, wind_speed: 7, received_at: DateTime.add(now, -6 * 3600, :second) }) # Create non-weather packet non_weather = PacketsFixtures.packet_fixture(%{ sender: "WX1", comment: "No weather data", received_at: DateTime.add(now, -3 * 3600, :second) }) {:ok, weather_packets: [weather_packet1, weather_packet2], non_weather: non_weather, start_time: start_time, end_time: end_time} end test "returns only weather packets for callsign", %{start_time: start_time, end_time: end_time} do results = Packets.get_weather_packets("WX1", start_time, end_time) # Should only include packets with weather data assert length(results) == 2 assert Enum.all?(results, fn p -> not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.wind_speed) end) end test "respects time range", %{end_time: end_time} do # Query for only last 8 hours start_time = DateTime.add(end_time, -8 * 3600, :second) results = Packets.get_weather_packets("WX1", start_time, end_time) # Should only include the most recent weather packet assert length(results) == 1 assert hd(results).temperature == 73.0 end end describe "get_total_packet_count/0" do test "returns count of all packets" do # Just verify it returns a number count = Packets.get_total_packet_count() assert is_integer(count) assert count >= 0 end test "returns 0 when no packets exist" do # Can't easily clear all packets in a test DB that might have other tests running # Just verify the function works count = Packets.get_total_packet_count() assert is_integer(count) end end describe "get_oldest_packet_timestamp/0" do test "returns timestamp of oldest packet" do # Create an old packet now = DateTime.utc_now() old_time = DateTime.add(now, -365 * 24 * 3600, :second) PacketsFixtures.packet_fixture(%{sender: "OLD", received_at: old_time}) PacketsFixtures.packet_fixture(%{sender: "NEW", received_at: now}) oldest = Packets.get_oldest_packet_timestamp() # Just verify we got a timestamp back assert is_struct(oldest, DateTime) end test "returns nil when no packets exist" do Repo.delete_all(Packet) assert is_nil(Packets.get_oldest_packet_timestamp()) end end describe "clean_old_packets/0" do setup do # Defensive: ensure a clean slate so residual rows from prior runs # (e.g. data committed outside a sandbox transaction) don't inflate # the deletion count. Repo.delete_all(Packet) :ok end test "deletes packets older than retention period" do now = DateTime.utc_now() # Create old packet (400 days ago) old_packet = PacketsFixtures.packet_fixture(%{ sender: "OLD", received_at: DateTime.add(now, -400 * 24 * 3600, :second) }) # Create recent packet recent_packet = PacketsFixtures.packet_fixture(%{ sender: "RECENT", received_at: now }) # Run cleanup deleted_count = Packets.clean_old_packets() assert deleted_count == 1 assert is_nil(Repo.get(Packet, old_packet.id)) assert Repo.get(Packet, recent_packet.id) end end describe "clean_packets_older_than/1" do setup do Repo.delete_all(Packet) :ok end test "deletes packets older than specified days" do now = DateTime.utc_now() # Create packets at different ages old_packet = PacketsFixtures.packet_fixture(%{ sender: "OLD", received_at: DateTime.add(now, -10 * 24 * 3600, :second) }) recent_packet = PacketsFixtures.packet_fixture(%{ sender: "RECENT", received_at: DateTime.add(now, -5 * 24 * 3600, :second) }) # Clean packets older than 7 days assert {:ok, 1} = Packets.clean_packets_older_than(7) assert is_nil(Repo.get(Packet, old_packet.id)) assert Repo.get(Packet, recent_packet.id) end test "validates positive days parameter" do # Should guard against invalid params assert_raise FunctionClauseError, fn -> Packets.clean_packets_older_than(0) end end end describe "get_latest_packet_for_callsign/1" do test "returns most recent packet for callsign" do now = DateTime.utc_now() # Create multiple packets for same callsign _old = PacketsFixtures.packet_fixture(%{ sender: "TEST1", received_at: DateTime.add(now, -3600, :second), comment: "Old" }) recent = PacketsFixtures.packet_fixture(%{ sender: "TEST1", received_at: now, comment: "Recent" }) result = Packets.get_latest_packet_for_callsign("TEST1") assert result.id == recent.id assert result.comment == "Recent" end test "returns nil for non-existent callsign" do assert is_nil(Packets.get_latest_packet_for_callsign("NONEXISTENT")) end end describe "get_latest_weather_packet/1" do test "returns most recent weather packet" do now = DateTime.utc_now() # Create non-weather packet PacketsFixtures.packet_fixture(%{ sender: "WX1", received_at: DateTime.add(now, -7200, :second), comment: "Position only" }) # Create old weather packet PacketsFixtures.packet_fixture(%{ sender: "WX1", received_at: DateTime.add(now, -3600, :second), temperature: 70.0, humidity: 50 }) # Create recent weather packet recent_wx = PacketsFixtures.packet_fixture(%{ sender: "WX1", received_at: now, temperature: 72.0, humidity: 55, wind_speed: 5 }) result = Packets.get_latest_weather_packet("WX1") assert result.id == recent_wx.id assert result.temperature == 72.0 end test "returns nil when no weather packets exist" do PacketsFixtures.packet_fixture(%{sender: "NOWX", comment: "No weather"}) assert is_nil(Packets.get_latest_weather_packet("NOWX")) end end describe "has_weather_packets?/1" do test "returns true when weather packets exist" do PacketsFixtures.packet_fixture(%{ sender: "WX1", temperature: 72.0 }) assert Packets.has_weather_packets?("WX1") end test "returns false when no weather packets exist" do PacketsFixtures.packet_fixture(%{sender: "NOWX", comment: "No weather"}) refute Packets.has_weather_packets?("NOWX") end end describe "weather_callsigns/1" do test "returns callsigns that have weather packets" do PacketsFixtures.packet_fixture(%{sender: "WX-BATCH1", temperature: 72.0}) PacketsFixtures.packet_fixture(%{sender: "WX-BATCH2", wind_speed: 10.0}) PacketsFixtures.packet_fixture(%{sender: "NOWX-BATCH", comment: "No weather"}) result = Packets.weather_callsigns(["WX-BATCH1", "WX-BATCH2", "NOWX-BATCH"]) assert MapSet.member?(result, "WX-BATCH1") assert MapSet.member?(result, "WX-BATCH2") refute MapSet.member?(result, "NOWX-BATCH") end test "returns empty set for empty input" do assert MapSet.new() == Packets.weather_callsigns([]) end test "is case-insensitive" do PacketsFixtures.packet_fixture(%{sender: "WX-CASE", temperature: 72.0}) result = Packets.weather_callsigns(["wx-case"]) assert MapSet.member?(result, "WX-CASE") end end describe "get_packets_for_replay/1" do setup do now = DateTime.utc_now() start_time = DateTime.add(now, -3600, :second) end_time = now # Create packets in time range p1 = PacketsFixtures.packet_fixture(%{ sender: "REPLAY1", received_at: DateTime.add(start_time, 900, :second), lat: 39.0, lon: -98.0 }) p2 = PacketsFixtures.packet_fixture(%{ sender: "REPLAY2", received_at: DateTime.add(start_time, 1800, :second), lat: 39.1, lon: -98.1 }) # Create packet outside time range _old = PacketsFixtures.packet_fixture(%{ sender: "OLD", received_at: DateTime.add(start_time, -3600, :second), lat: 39.2, lon: -98.2 }) {:ok, packets: [p1, p2], start_time: start_time, end_time: end_time} end test "returns packets in chronological order", %{start_time: start_time, end_time: end_time} do results = Packets.get_packets_for_replay(%{ start_time: start_time, end_time: end_time }) assert length(results) == 2 assert hd(results).sender == "REPLAY1" assert List.last(results).sender == "REPLAY2" end test "filters by bounds", %{start_time: start_time, end_time: end_time} do # Only includes REPLAY1 bounds = [38.5, -98.5, 39.05, -97.5] results = Packets.get_packets_for_replay(%{ start_time: start_time, end_time: end_time, bounds: bounds }) # The bounds filtering may not work exactly as expected in test # Just verify we get results (will be empty with these bounds) assert results == [] end end describe "stream_packets_for_replay/1" do test "returns stream with timing information" do now = DateTime.utc_now() start_time = DateTime.add(now, -300, :second) # Create packets with 60 second intervals _p1 = PacketsFixtures.packet_fixture(%{ sender: "STREAM1", received_at: start_time, lat: 39.0, lon: -98.0 }) _p2 = PacketsFixtures.packet_fixture(%{ sender: "STREAM2", received_at: DateTime.add(start_time, 60, :second), lat: 39.1, lon: -98.1 }) stream = Packets.stream_packets_for_replay(%{ start_time: start_time, end_time: now, # 2x speed playback_speed: 2.0 }) [{delay1, packet1}, {delay2, packet2}] = Enum.take(stream, 2) # First packet has no delay assert delay1 == 0 assert packet1.sender == "STREAM1" # 60 seconds / 2.0 speed = 30 seconds assert_in_delta delay2, 30.0, 0.1 assert packet2.sender == "STREAM2" end end describe "get_historical_packet_count/1" do test "counts packets matching criteria" do now = DateTime.utc_now() # Create packets PacketsFixtures.packet_fixture(%{ sender: "COUNT1", lat: 39.0, lon: -98.0, received_at: DateTime.add(now, -3600, :second) }) PacketsFixtures.packet_fixture(%{ sender: "COUNT2", lat: 39.1, lon: -98.1, received_at: DateTime.add(now, -1800, :second) }) # Count all recent packets count = Packets.get_historical_packet_count(%{ start_time: DateTime.add(now, -7200, :second), end_time: now }) assert count >= 2 end test "does not crash with invalid bounds" do count = Packets.get_historical_packet_count(%{bounds: "invalid"}) assert is_integer(count) and count >= 0 end end describe "get_other_ssids/2" do test "returns other SSIDs for a base callsign" do now = DateTime.utc_now() # Create packets for multiple SSIDs of the same base callsign PacketsFixtures.packet_fixture(%{ sender: "AE5PL", base_callsign: "AE5PL", ssid: "0", received_at: DateTime.add(now, -1800, :second), symbol_table_id: "/", symbol_code: "-" }) PacketsFixtures.packet_fixture(%{ sender: "AE5PL-1", base_callsign: "AE5PL", ssid: "1", received_at: DateTime.add(now, -900, :second), symbol_table_id: "/", symbol_code: ">" }) PacketsFixtures.packet_fixture(%{ sender: "AE5PL-15", base_callsign: "AE5PL", ssid: "15", received_at: DateTime.add(now, -300, :second), symbol_table_id: "/", symbol_code: "#" }) # Query for SSIDs other than AE5PL (the base callsign with no SSID) results = Packets.get_other_ssids("AE5PL") callsigns = Enum.map(results, & &1.callsign) assert "AE5PL-1" in callsigns assert "AE5PL-15" in callsigns refute "AE5PL" in callsigns end test "returns other SSIDs when querying from an SSID variant" do now = DateTime.utc_now() PacketsFixtures.packet_fixture(%{ sender: "W5ABC", base_callsign: "W5ABC", ssid: "0", received_at: DateTime.add(now, -1800, :second) }) PacketsFixtures.packet_fixture(%{ sender: "W5ABC-9", base_callsign: "W5ABC", ssid: "9", received_at: DateTime.add(now, -900, :second) }) # Query from the -9 SSID should show the base callsign results = Packets.get_other_ssids("W5ABC-9") callsigns = Enum.map(results, & &1.callsign) assert "W5ABC" in callsigns refute "W5ABC-9" in callsigns end test "returns empty list when no other SSIDs exist" do PacketsFixtures.packet_fixture(%{ sender: "LONELY", base_callsign: "LONELY", ssid: "0" }) results = Packets.get_other_ssids("LONELY") assert results == [] end test "only returns SSIDs with recent packets" do now = DateTime.utc_now() # Create a recent packet PacketsFixtures.packet_fixture(%{ sender: "RECENT-1", base_callsign: "RECENT", ssid: "1", received_at: DateTime.add(now, -300, :second) }) # Create an old packet (2 hours ago, outside 1-hour window) PacketsFixtures.packet_fixture(%{ sender: "RECENT-2", base_callsign: "RECENT", ssid: "2", received_at: DateTime.add(now, -7200, :second) }) results = Packets.get_other_ssids("RECENT") callsigns = Enum.map(results, & &1.callsign) assert "RECENT-1" in callsigns refute "RECENT-2" in callsigns end test "includes symbol information and received_at in results" do now = DateTime.utc_now() PacketsFixtures.packet_fixture(%{ sender: "SYM-1", base_callsign: "SYM", ssid: "1", received_at: now, symbol_table_id: "/", symbol_code: ">" }) [result] = Packets.get_other_ssids("SYM") assert result.callsign == "SYM-1" assert result.ssid == "1" assert result.packet.symbol_table_id == "/" assert result.packet.symbol_code == ">" # Returns raw received_at DateTime, not a formatted map assert %DateTime{} = result.received_at refute Map.has_key?(result, :last_heard) end end describe "get_last_hour_packets/0" do test "returns packets from last hour" do now = DateTime.utc_now() # Create recent packet _recent = PacketsFixtures.packet_fixture(%{ sender: "RECENT", received_at: DateTime.add(now, -1800, :second), lat: 39.0, lon: -98.0 }) # Create old packet _old = PacketsFixtures.packet_fixture(%{ sender: "OLD", received_at: DateTime.add(now, -7200, :second), lat: 39.1, lon: -98.1 }) results = Packets.get_last_hour_packets() callsigns = Enum.map(results, & &1.sender) assert "RECENT" in callsigns refute "OLD" in callsigns end test "returns empty list on error" do # This should not crash even if there's an issue results = Packets.get_last_hour_packets() assert results == [] end end describe "get_latest_positions_for_callsigns/1" do test "returns a map from callsign to the latest packet" do _old = PacketsFixtures.packet_fixture(%{ sender: "POS1", received_at: DateTime.add(DateTime.utc_now(), -7200, :second), lat: 39.0, lon: -98.0, has_position: true }) _new = PacketsFixtures.packet_fixture(%{ sender: "POS1", received_at: DateTime.add(DateTime.utc_now(), -60, :second), lat: 39.5, lon: -98.5, has_position: true }) _other = PacketsFixtures.packet_fixture(%{ sender: "POS2", received_at: DateTime.utc_now(), lat: 40.0, lon: -99.0, has_position: true }) result = Packets.get_latest_positions_for_callsigns(["POS1", "POS2", "MISSING"]) callsigns = Enum.map(result, & &1.callsign) assert "POS1" in callsigns end test "returns an empty list for an empty callsign list" do assert Packets.get_latest_positions_for_callsigns([]) == [] end end describe "get_latest_packets_for_callsigns/1" do test "returns the latest packet for each callsign" do _ = PacketsFixtures.packet_fixture(%{ sender: "LPFC1", received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) _ = PacketsFixtures.packet_fixture(%{ sender: "LPFC2", received_at: DateTime.utc_now() }) result = Packets.get_latest_packets_for_callsigns(["LPFC1", "LPFC2", "NOPE"]) assert length(result) == 2 end test "handles an empty list" do result = Packets.get_latest_packets_for_callsigns([]) assert result == [] end end describe "store_bad_packet/2 map and binary inputs" do test "stores a bad packet from a binary raw packet" do assert {:ok, _} = Packets.store_bad_packet("BAD-RAW>APRS:corrupt", %{message: "parse error", type: "ParseError"}) end test "stores a bad packet from a map payload" do assert {:ok, _} = Packets.store_bad_packet( %{sender: "BAD-MAP", raw: "BAD-MAP>APRS:junk"}, %{message: "schema error", type: "ValidationError"} ) end end describe "default-arg function heads (zero-arg)" do test "get_packets_for_replay/0 with no args returns a list" do result = Packets.get_packets_for_replay() assert [%{sender: _} | _] = result end test "get_recent_packets_for_map/0 with no args returns a list" do result = Packets.get_recent_packets_for_map() assert result == [] end test "stream_packets_for_replay/0 with no args returns a Stream" do stream = Packets.stream_packets_for_replay() # Streams are functions or %Stream{} structs. assert is_function(stream) # Materialize to confirm it's enumerable and yields expected tuples. [{_delay, %{sender: _}} | _] = Enum.to_list(stream) end test "get_historical_packet_count/0 returns a non-negative integer" do result = Packets.get_historical_packet_count() assert is_integer(result) and result >= 0 end end describe "store_packet/1 device_identifier non-binary fallback" do test "extracts device_identifier from data_extended even when atom" do # extract_device_identifier may return atoms in some paths; ensure it gets # stringified through get_canonical_device_identifier/1's fallback. packet_data = %{ sender: "DEV-1", base_callsign: "DEV", ssid: "1", destination: "APN001", raw_packet: "DEV-1>APN001:>test", data_type: "status", symbol_table_id: "/", symbol_code: ">", lat: 33.0, lon: -96.0 } assert {:ok, packet} = Packets.store_packet(packet_data) assert String.contains?(packet.device_identifier, "APN001") end end describe "extract_position_from_mic_e fallback for incomplete MicE map" do test "incomplete MicE map without lat_minutes uses fallback {nil, nil}" do packet_data = %{ sender: "MICE-INC", base_callsign: "MICE", ssid: "1", destination: "APRS", raw_packet: "MICE-INC>APRS:test", data_type: "mic_e", symbol_table_id: "/", symbol_code: ">", # data_extended without enough keys to satisfy the "full MicE map" head data_extended: %{lat_degrees: 33} } # store_packet may succeed without coordinates or it may fail validation. # Either is fine — we only care that the fallback head is exercised. result = Packets.store_packet(packet_data) assert {:ok, _} = result end end describe "store_packet/1 with string-keyed nested position" do test "extracts position with string-keyed 'position' map and atom 'latitude' key" do # Targets find_coordinate_value heads at lines 189-192 — the four # combinations of string outer key + (atom|string) inner keys. packet_data = %{ sender: "POS-MIX", base_callsign: "POS", ssid: "1", destination: "APRS", raw_packet: "POS-MIX>APRS:test", data_type: "position", symbol_table_id: "/", symbol_code: ">", data_extended: %{ "position" => %{latitude: 33.5, longitude: -96.5} } } assert {:ok, _} = Packets.store_packet(packet_data) end test "extracts position with string-keyed nested position and string keys" do packet_data = %{ sender: "POS-STR", base_callsign: "POS", ssid: "1", destination: "APRS", raw_packet: "POS-STR>APRS:test", data_type: "position", symbol_table_id: "/", symbol_code: ">", data_extended: %{ "position" => %{"latitude" => 33.5, "longitude" => -96.5} } } assert {:ok, _} = Packets.store_packet(packet_data) end end describe "round_coordinate via store_packet with binary string lat/lon" do test "lat/lon as parseable strings via Float.parse → rounded" do packet_data = %{ sender: "BIN-LATLON", base_callsign: "BIN", ssid: "1", destination: "APRS", raw_packet: "BIN-LATLON>APRS:test", data_type: "position", symbol_table_id: "/", symbol_code: ">", lat: "33.123456789", lon: "-96.987654321" } assert {:ok, _} = Packets.store_packet(packet_data) end test "unparseable lat string falls through round_coordinate to nil" do packet_data = %{ sender: "JUNK-LATLON", base_callsign: "JUNK", ssid: "1", destination: "APRS", raw_packet: "JUNK-LATLON>APRS:test", data_type: "position", symbol_table_id: "/", symbol_code: ">", lat: "not-a-number", lon: "also-junk" } assert {:error, :validation_error} = Packets.store_packet(packet_data) end end describe "get_nearby_stations/4 with empty result set" do test "returns an empty list when no stations are nearby" do result = Packets.get_nearby_stations(85.0, 0.0, nil, %{}) assert result == [] end test "uses default arguments via /2" do result = Packets.get_nearby_stations(85.0, 0.0) assert result == [] end end describe "round_coordinate via store_packet with various input types" do test "store_packet with Decimal lat/lon hits round_coordinate(%Decimal{})" do packet = %{ sender: "DEC-COORD", base_callsign: "DEC-COORD", ssid: "0", destination: "APRS", raw_packet: "DEC-COORD>APRS", data_type: "position", lat: Decimal.new("33.123456789"), lon: Decimal.new("-96.987654321") } assert {:ok, _} = Packets.store_packet(packet) end test "store_packet with integer lat/lon hits round_coordinate(integer)" do packet = %{ sender: "INT-COORD", base_callsign: "INT-COORD", ssid: "0", destination: "APRS", raw_packet: "INT-COORD>APRS", data_type: "position", lat: 33, lon: -96 } assert {:ok, _} = Packets.store_packet(packet) end test "store_packet with valid binary lat/lon hits round_coordinate(binary) parse path" do packet = %{ sender: "BIN-COORD", base_callsign: "BIN-COORD", ssid: "0", destination: "APRS", raw_packet: "BIN-COORD>APRS", data_type: "position", lat: "33.5", lon: "-96.5" } assert {:ok, _} = Packets.store_packet(packet) end end describe "store_bad_packet/2 with various error shapes" do test "store_bad_packet/2 with binary packet_data and a struct error uses Exception.message" do assert {:ok, _} = Packets.store_bad_packet("rawpacket-binary", %ArgumentError{message: "boom"}) end test "store_bad_packet/2 with binary packet_data and a non-struct error uses inspect" do assert {:ok, _} = Packets.store_bad_packet("rawpacket-binary", :some_atom) end test "store_bad_packet/2 with map packet_data and a typed map" do assert {:ok, _} = Packets.store_bad_packet(%{raw_packet: "raw-x"}, %{type: "MyType", message: "explained"}) end test "store_bad_packet/2 with map packet_data and a struct error" do assert {:ok, _} = Packets.store_bad_packet(%{raw_packet: "raw-y"}, %ArgumentError{message: "boom"}) end test "store_bad_packet/2 with map missing :raw_packet falls back to inspect" do assert {:ok, _} = Packets.store_bad_packet(%{some: "junk"}, %{type: "X", message: "Y"}) end test "store_bad_packet/2 with binary packet_data and a typed-map error" do assert {:ok, _} = Packets.store_bad_packet("rawpacket-binary", %{type: "Custom", message: "msg"}) end end end