- Delete config/appsignal.exs (was never wired into mix.exs or any other config file) - Migration: fix decrement_packet_sequence trigger to handle the case where currval() hasn't been called in the current DB session, matching the same exception handling already present in get_packet_count() - Fix get_historical_packet_count test: invalid non-list bounds are silently ignored (no error), so assert is_integer not count == 0 - Add Repo.delete_all(Packet) setup blocks in PacketsOldestTest and PacketConsumerTest to clear any committed DB state before tests that depend on an empty packets table
1437 lines
42 KiB
Elixir
1437 lines
42 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 is_binary(packet.device_identifier)
|
|
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.
|
|
result = Packets.store_packet(packet_data)
|
|
# Should either succeed (no position) or be a validation error.
|
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
|
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 "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.
|
|
result = Packets.store_packet(packet_data)
|
|
# Either stores successfully without position or validation fails.
|
|
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
|
end
|
|
|
|
test "extracts southern-hemisphere coordinates from a MicE struct via apply_direction" do
|
|
packet_data = %{
|
|
sender: "MICESOUTH",
|
|
base_callsign: "MICESOUTH",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
raw_packet: "MICESOUTH>APRS",
|
|
data_type: "mic_e",
|
|
data_extended: %MicE{
|
|
lat_degrees: 33,
|
|
lat_minutes: 30,
|
|
lat_direction: :south,
|
|
lon_degrees: 96,
|
|
lon_minutes: 30,
|
|
lon_direction: :east
|
|
}
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
# :south branch must negate the latitude; :east keeps longitude positive.
|
|
# Packet.lat is stored as Decimal — compare via Decimal.to_float.
|
|
lat = Decimal.to_float(packet.lat)
|
|
lon = Decimal.to_float(packet.lon)
|
|
assert lat < 0
|
|
assert lon > 0
|
|
end
|
|
|
|
test "extracts northern + western coordinates from a MicE struct via apply_direction" do
|
|
packet_data = %{
|
|
sender: "MICENW",
|
|
base_callsign: "MICENW",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
raw_packet: "MICENW>APRS",
|
|
data_type: "mic_e",
|
|
data_extended: %MicE{
|
|
lat_degrees: 33,
|
|
lat_minutes: 30,
|
|
lat_direction: :north,
|
|
lon_degrees: 96,
|
|
lon_minutes: 30,
|
|
lon_direction: :west
|
|
}
|
|
}
|
|
|
|
assert {:ok, packet} = Packets.store_packet(packet_data)
|
|
lat = Decimal.to_float(packet.lat)
|
|
lon = Decimal.to_float(packet.lon)
|
|
assert lat > 0
|
|
assert lon < 0
|
|
end
|
|
|
|
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)
|
|
# Either ValidationError or :storage_exception — both paths log and store BadPacket.
|
|
assert result == {:error, :validation_error} or result == {:error, :storage_exception}
|
|
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 map, not a Packet struct
|
|
refute is_struct(first, Packet)
|
|
assert is_map(first)
|
|
|
|
# Should have the essential fields
|
|
assert Map.has_key?(first, :id)
|
|
assert Map.has_key?(first, :sender)
|
|
assert Map.has_key?(first, :lat)
|
|
assert Map.has_key?(first, :lon)
|
|
assert Map.has_key?(first, :received_at)
|
|
assert Map.has_key?(first, :symbol_table_id)
|
|
assert Map.has_key?(first, :symbol_code)
|
|
assert Map.has_key?(first, :comment)
|
|
assert Map.has_key?(first, :path)
|
|
assert Map.has_key?(first, :object_name)
|
|
assert Map.has_key?(first, :item_name)
|
|
|
|
# Should have weather fields
|
|
assert Map.has_key?(first, :temperature)
|
|
assert Map.has_key?(first, :humidity)
|
|
|
|
# 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
|
|
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
|
|
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
|
|
assert is_list(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 is_list(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"])
|
|
assert is_list(result)
|
|
callsigns = Enum.map(result, & &1.callsign)
|
|
assert "POS1" in callsigns or "POS2" 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 is_list(result) or is_map(result)
|
|
end
|
|
|
|
test "handles an empty list" do
|
|
result = Packets.get_latest_packets_for_callsigns([])
|
|
assert result == [] or 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
|
|
end
|