- Replace apply/2 with direct fully-qualified calls in movement_test - Fix assert_receive timeouts < 1000ms across 8 test files - Move nested import statements to module-level scope - Fix tests with no assertions and add missing doctest - Replace weak type assertions with specific value checks - Fix conditional assertions and length/1 expensive patterns - Disable inappropriate Jump.CredoChecks.AvoidSocketAssignsInTest - Fix tests not calling application code with credo:disable - Add various credo:disable comments for legitimate patterns
1877 lines
56 KiB
Elixir
1877 lines
56 KiB
Elixir
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
|