aprs.me/test/aprsme/packet_consumer_test.exs
Graham McIntire 0d4314b880
Fix object positioning to use object name instead of origin callsign
Objects and items were being displayed at the origin station's location
instead of their actual coordinates because the broadcast used the
sender (origin callsign) as the identifier instead of object_name/item_name.

For example, object CALGRY sent by VE6RWB-15 was appearing at VE6RWB-15's
location instead of CALGRY's actual coordinates.

Changes:
- Modified broadcast_single_packet to use object_name for objects
  and item_name for items as the identifier instead of sender
- Added tests verifying objects and items broadcast with correct identifier
- Objects/items now display at their specified coordinates, not origin station

Fixes the positioning bug where objects appeared at sender location.
2026-03-22 15:19:52 -05:00

498 lines
15 KiB
Elixir

defmodule Aprsme.PacketConsumerTest do
use Aprsme.DataCase, async: false
import Ecto.Query
alias Aprsme.Packet
alias Aprsme.PacketConsumer
alias Aprsme.StreamingPacketsPubSub
describe "object broadcast with correct identifier" do
test "broadcasts object packets using object_name instead of sender" do
# Subscribe to packets in test bounds
bounds = %{north: 52.0, south: 50.0, east: -113.0, west: -115.0}
StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
# Object packet - sender is VE6RWB-15, object name is CALGRY
events = [
%{
sender: "VE6RWB-15",
lat: 51.044733,
lon: -114.062019,
data_type: "object",
destination: "APRS",
path: "TCPIP*,qAC,T2ROMANIA",
object_name: "CALGRY",
information_field: ";CALGRY *111111z5102.68N/11403.72W-",
raw_packet: "VE6RWB-15>APRS,TCPIP*,qAC,T2ROMANIA:;CALGRY *111111z5102.68N/11403.72W-"
}
]
state = %{
batch: [],
batch_length: 0,
batch_size: 1,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
PacketConsumer.handle_events(events, nil, state)
# Wait for async broadcast task to complete
Process.sleep(50)
# Should receive the packet via PubSub with object_name as sender
assert_receive {:streaming_packet, packet}, 1000
assert packet.sender == "CALGRY", "Expected sender to be object name CALGRY, got #{packet.sender}"
assert packet.latitude == 51.044733
assert packet.longitude == -114.062019
end
test "broadcasts item packets using item_name instead of sender" do
# Subscribe to packets in test bounds
bounds = %{north: 40.0, south: 30.0, east: -70.0, west: -80.0}
StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
# Item packet - sender is ITEMTEST, item name is MyItem
events = [
%{
sender: "ITEMTEST",
lat: 35.0,
lon: -75.0,
data_type: "item",
destination: "APRS",
path: "WIDE1-1",
item_name: "MyItem",
information_field: ")MyItem!3500.00N/07500.00W-Test item",
raw_packet: "ITEMTEST>APRS,WIDE1-1:)MyItem!3500.00N/07500.00W-Test item"
}
]
state = %{
batch: [],
batch_length: 0,
batch_size: 1,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
PacketConsumer.handle_events(events, nil, state)
# Wait for async broadcast task to complete
Process.sleep(50)
# Should receive the packet via PubSub with item_name as sender
assert_receive {:streaming_packet, packet}, 1000
assert packet.sender == "MyItem", "Expected sender to be item name MyItem, got #{packet.sender}"
assert packet.latitude == 35.0
assert packet.longitude == -75.0
end
end
describe "object name extraction" do
test "extracts object name with hyphens from information_field" do
events = [
%{
sender: "db0sda",
lat: 33.169,
lon: -96.492,
data_type: "object",
destination: "APRS",
path: "TCPIP*,qAC,SECOND",
information_field: ";P-K5SGD *192200z3310.16NP09629.53W#PHG0-100DAPNET POCSAG Transmitter",
raw_packet: "db0sda>APRS,TCPIP*,qAC,SECOND:;P-K5SGD *192200z3310.16NP09629.53W#"
}
]
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
{:noreply, [], _} = PacketConsumer.handle_events(events, nil, state)
Process.sleep(50)
packet = Repo.one(from p in Packet, where: p.sender == "db0sda")
assert packet
assert packet.object_name == "P-K5SGD"
end
test "extracts object name with special characters" do
events = [
%{
sender: "OBJTEST1",
lat: 35.0,
lon: -75.0,
data_type: "object",
destination: "APRS",
path: "WIDE1-1",
information_field: ";#146.760 *192200z3500.00N/07500.00W#Repeater",
raw_packet: "OBJTEST1>APRS:;#146.760 *192200z3500.00N/07500.00W#Repeater"
}
]
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
{:noreply, [], _} = PacketConsumer.handle_events(events, nil, state)
Process.sleep(50)
packet = Repo.one(from p in Packet, where: p.sender == "OBJTEST1")
assert packet
assert packet.object_name == "#146.760"
end
test "extracts object name for killed objects" do
events = [
%{
sender: "OBJTEST2",
lat: 35.0,
lon: -75.0,
data_type: "object",
destination: "APRS",
path: "WIDE1-1",
information_field: ";P-KILLED _192200z3500.00N/07500.00W#Removed",
raw_packet: "OBJTEST2>APRS:;P-KILLED _192200z3500.00N/07500.00W#Removed"
}
]
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
{:noreply, [], _} = PacketConsumer.handle_events(events, nil, state)
Process.sleep(50)
packet = Repo.one(from p in Packet, where: p.sender == "OBJTEST2")
assert packet
assert packet.object_name == "P-KILLED"
end
end
describe "handle_events/3 with stream processing" do
test "processes packets using streams without memory accumulation" do
# Create test packets
events =
for i <- 1..1000 do
%{
sender: "K#{i}TEST",
lat: 35.0 + :rand.uniform() * 5,
lon: -75.0 + :rand.uniform() * 5,
destination: "APRS",
path: "WIDE1-1",
information_field: "Test packet #{i}",
data_type: "position",
raw_packet: "K#{i}TEST>APRS,WIDE1-1:Test packet #{i}"
}
end
# Initialize consumer state
state = %{
batch: [],
batch_length: 0,
batch_size: 100,
batch_timeout: 1000,
max_batch_size: 1000,
timer: nil
}
# Process events and verify memory usage
{:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
# Verify batch was processed (empty batch after processing)
assert new_state.batch == []
# Give time for async operations
Process.sleep(20)
# Check that packets were inserted
assert Repo.aggregate(Packet, :count) > 0
end
test "handles invalid packets gracefully" do
# Mix of valid and invalid packets
events = [
%{
sender: "VALID1",
lat: 35.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test packet 1",
raw_packet: "VALID1>APRS,WIDE1-1:Test packet 1"
},
# Invalid - no sender
%{sender: nil, lat: 35.0, lon: -75.0},
# Invalid - empty sender
%{sender: "", lat: 35.0, lon: -75.0},
# Invalid coordinates (lat > 90)
%{
sender: "VALID2",
lat: 91.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test packet 2",
raw_packet: "VALID2>APRS,WIDE1-1:Test packet 2"
},
%{
sender: "VALID3",
lat: 35.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test packet 3",
raw_packet: "VALID3>APRS,WIDE1-1:Test packet 3"
}
]
state = %{
batch: [],
batch_length: 0,
# Set batch size to 5 to trigger immediate processing
batch_size: 5,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
# Give more time for async processing and broadcasts
Process.sleep(50)
# Valid packets (with valid sender) should be inserted
# Note: VALID2 has invalid coordinates but valid sender, so it's still inserted
packets = Repo.all(Packet)
# Should have 3 packets: VALID1, VALID2 (with nil location), and VALID3
assert length(packets) == 3
# Check that all valid senders are present
senders = packets |> Enum.map(& &1.sender) |> Enum.sort()
assert senders == ["VALID1", "VALID2", "VALID3"]
# Check that VALID2 has no location due to invalid coordinates
valid2_packet = Enum.find(packets, &(&1.sender == "VALID2"))
assert valid2_packet.location == nil
# Invalid latitude stored
assert Decimal.equal?(valid2_packet.lat, Decimal.new("91.0"))
# Check that VALID1 and VALID3 have valid locations
valid_location_packets = Enum.filter(packets, &(&1.sender in ["VALID1", "VALID3"]))
assert Enum.all?(valid_location_packets, &(&1.location != nil))
end
test "broadcasts packets to StreamingPacketsPubSub" do
# Subscribe to packets in test bounds
bounds = %{north: 40.0, south: 30.0, east: -70.0, west: -80.0}
StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
events = [
%{
sender: "BROADCAST1",
lat: 35.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test broadcast"
}
]
state = %{
batch: [],
batch_length: 0,
batch_size: 1,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
PacketConsumer.handle_events(events, nil, state)
# Wait for async broadcast task to complete
Process.sleep(50)
# Should receive the packet via PubSub
assert_receive {:streaming_packet, packet}, 1000
assert packet.sender == "BROADCAST1"
end
test "respects max_batch_size and drops excess packets" do
import ExUnit.CaptureLog
# Temporarily enable warning level for this test
Logger.configure(level: :warning)
# Create more packets than max_batch_size
events =
for i <- 1..150 do
%{
sender: "K#{i}DROP",
lat: 35.0,
lon: -75.0,
data_type: "position"
}
end
state = %{
batch: [],
batch_length: 0,
batch_size: 50,
batch_timeout: 1000,
# Only 100 will be processed
max_batch_size: 100,
timer: nil
}
# Capture logs to verify warning
log =
capture_log(fn ->
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
Process.sleep(20)
end)
# Should log warning about dropped packets
assert log =~ "Dropped 50 packets due to batch size limit"
# Reset log level
Logger.configure(level: :error)
# Only max_batch_size packets should be processed
packet_count = Repo.aggregate(Packet, :count)
assert packet_count <= 100
end
end
describe "batch insert resilience" do
test "recovers from batch insert failure by falling back to individual inserts" do
# We test this by inserting a batch that includes a packet which would
# cause a constraint violation only in batch mode.
# Since we can't easily trigger Repo.insert_all failures in test,
# we verify the fallback path exists by testing process_chunk indirectly:
# inserting valid packets should succeed even when called through the
# batch processing pipeline.
events = [
%{
sender: "RETRY1",
lat: 35.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test retry 1",
raw_packet: "RETRY1>APRS:Test retry 1"
},
%{
sender: "RETRY2",
lat: 36.0,
lon: -76.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test retry 2",
raw_packet: "RETRY2>APRS:Test retry 2"
}
]
state = %{
batch: [],
batch_length: 0,
batch_size: 2,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
Process.sleep(50)
packets = Repo.all(Packet)
senders = packets |> Enum.map(& &1.sender) |> Enum.sort()
assert "RETRY1" in senders
assert "RETRY2" in senders
end
test "fallback individual inserts still broadcast packets" do
bounds = %{north: 40.0, south: 30.0, east: -70.0, west: -80.0}
StreamingPacketsPubSub.subscribe_to_bounds(self(), bounds)
events = [
%{
sender: "FALLBACK1",
lat: 35.0,
lon: -75.0,
data_type: "position",
destination: "APRS",
path: "WIDE1-1",
information_field: "Test fallback"
}
]
state = %{
batch: [],
batch_length: 0,
batch_size: 1,
batch_timeout: 1000,
max_batch_size: 100,
timer: nil
}
PacketConsumer.handle_events(events, nil, state)
# Wait for async broadcast task to complete
Process.sleep(50)
assert_receive {:streaming_packet, packet}, 1000
assert packet.sender == "FALLBACK1"
end
end
describe "memory efficiency" do
test "processes large batches without excessive memory growth" do
# Monitor memory before processing
:erlang.garbage_collect()
memory_before = :erlang.memory(:total)
# Create a smaller, more focused batch of packets
# Reduced from 5000 to 1000 packets and smaller payloads
events =
for i <- 1..1000 do
%{
sender: "K#{i}MEM",
lat: 35.0 + :rand.uniform() * 5,
lon: -75.0 + :rand.uniform() * 5,
data_type: "position",
# Smaller payload - 100 chars instead of 1000
information_field: String.duplicate("X", 100)
}
end
state = %{
batch: [],
batch_length: 0,
batch_size: 100,
batch_timeout: 100,
max_batch_size: 200,
timer: nil
}
# Process in chunks (simulating multiple handle_events calls)
final_state =
events
|> Enum.chunk_every(200)
|> Enum.reduce(state, fn chunk, acc_state ->
{:noreply, [], new_state} = PacketConsumer.handle_events(chunk, nil, acc_state)
new_state
end)
# Ensure we use the final_state to avoid warning
assert is_map(final_state)
# Force GC and check memory
:erlang.garbage_collect()
memory_after = :erlang.memory(:total)
memory_growth = memory_after - memory_before
# Memory growth should be reasonable (less than 10MB for 1000 packets)
assert memory_growth < 10_000_000
end
end
end