- 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
998 lines
31 KiB
Elixir
998 lines
31 KiB
Elixir
defmodule Aprsme.PacketConsumerTest do
|
|
use Aprsme.DataCase, async: false
|
|
|
|
import Ecto.Query
|
|
import ExUnit.CaptureLog
|
|
|
|
alias Aprsme.Packet
|
|
alias Aprsme.PacketConsumer
|
|
alias Aprsme.StreamingPacketsPubSub
|
|
|
|
describe "start_link/1" do
|
|
test "starts an unnamed GenStage consumer when no :name option is given" do
|
|
assert {:ok, pid} = PacketConsumer.start_link(subscribe_to: [])
|
|
assert is_pid(pid)
|
|
GenStage.stop(pid)
|
|
end
|
|
|
|
test "starts a named GenStage consumer when :name is provided" do
|
|
name = :"test_consumer_#{System.unique_integer([:positive])}"
|
|
assert {:ok, pid} = PacketConsumer.start_link(name: name, subscribe_to: [])
|
|
assert Process.whereis(name) == pid
|
|
GenStage.stop(pid)
|
|
end
|
|
end
|
|
|
|
describe "normalize_numeric_types via handle_events with integer weather field" do
|
|
test "integer temperature is coerced to float by normalize_numeric_types" do
|
|
events = [
|
|
%{
|
|
sender: "INT-T",
|
|
base_callsign: "INT-T",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
# Integer weather value should be coerced to float (line 774).
|
|
temperature: 72,
|
|
humidity: 50,
|
|
wind_speed: 5,
|
|
information_field: "test",
|
|
raw_packet: "INT-T>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "ssid passed as integer gets normalized to string by normalize_ssid" do
|
|
events = [
|
|
%{
|
|
sender: "SSID-INT",
|
|
base_callsign: "SSID-INT",
|
|
# Integer ssid hits the to_string branch in normalize_ssid (line 737).
|
|
ssid: 9,
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
information_field: "test",
|
|
raw_packet: "SSID-INT>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
end
|
|
|
|
describe "init/1" do
|
|
test "returns a :consumer tuple with default configuration" do
|
|
# Start an isolated consumer with no subscription to avoid contaminating
|
|
# the global packet pipeline. We skip the auto-subscribe by using :manual.
|
|
assert {:consumer, state, opts} =
|
|
PacketConsumer.init(
|
|
batch_size: 50,
|
|
batch_timeout: 500,
|
|
max_batch_size: 123,
|
|
subscribe_to: []
|
|
)
|
|
|
|
assert state.batch == []
|
|
assert state.batch_length == 0
|
|
assert state.batch_size == 50
|
|
assert state.batch_timeout == 500
|
|
assert state.max_batch_size == 123
|
|
assert is_reference(state.timer)
|
|
assert opts[:subscribe_to] == []
|
|
|
|
# Cancel the timer to avoid stray :process_batch messages later.
|
|
Process.cancel_timer(state.timer)
|
|
end
|
|
|
|
test "applies default batch sizing when not provided" do
|
|
assert {:consumer, state, _opts} = PacketConsumer.init(subscribe_to: [])
|
|
assert state.batch_size == 100
|
|
assert state.batch_timeout == 1000
|
|
assert state.max_batch_size == 1000
|
|
Process.cancel_timer(state.timer)
|
|
end
|
|
end
|
|
|
|
describe "item/object detection" do
|
|
test "detects object via info_field prefix ';'" do
|
|
events = [
|
|
%{
|
|
sender: "INFOBJ-1",
|
|
data_type: "position",
|
|
lat: 35.0,
|
|
lon: -75.0,
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
information_field: ";MYOBJ *192200z3500.00N/07500.00W#Test",
|
|
raw_packet: "INFOBJ-1>APRS",
|
|
data: %{"information_field" => ";MYOBJ *192200z3500.00N/07500.00W#Test"}
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
|
|
assert new_state.batch == []
|
|
end
|
|
|
|
test "detects item via :itemname" do
|
|
events = [
|
|
%{
|
|
sender: "ITEMS-1",
|
|
data_type: "item",
|
|
lat: 35.0,
|
|
lon: -75.0,
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
itemname: "MyCustomItem",
|
|
raw_packet: "ITEMS-1>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
|
|
assert new_state.batch == []
|
|
end
|
|
end
|
|
|
|
describe "field-conversion private helpers reached via handle_events" do
|
|
setup do
|
|
Repo.delete_all(Packet)
|
|
:ok
|
|
end
|
|
|
|
test "converts top-level latitude/longitude → lat/lon" do
|
|
events = [
|
|
%{
|
|
sender: "COORD-1",
|
|
base_callsign: "COORD-1",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
# convert_coordinate_field_names should rename these.
|
|
latitude: 33.0,
|
|
longitude: -96.0,
|
|
information_field: "test",
|
|
raw_packet: "COORD-1>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "converts integer timestamp to string and renames aprs_messaging? key" do
|
|
events = [
|
|
%{
|
|
sender: "FIELD-1",
|
|
base_callsign: "FIELD-1",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
# convert_field_names branches.
|
|
timestamp: 192_200,
|
|
aprs_messaging?: true,
|
|
information_field: "test",
|
|
raw_packet: "FIELD-1>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "patch_lat_lon_from_data_extended uses nested coordinates" do
|
|
events = [
|
|
%{
|
|
sender: "EXT-1",
|
|
base_callsign: "EXT-1",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
# No top-level lat/lon — exercise patch_lat_lon_from_data_extended.
|
|
data_extended: %{latitude: 34.5, longitude: -97.5},
|
|
information_field: "test",
|
|
raw_packet: "EXT-1>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "handles data_extended with nested :position map (atom keys)" do
|
|
events = [
|
|
%{
|
|
sender: "POS-A",
|
|
base_callsign: "POS-A",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
data_extended: %{
|
|
position: %{latitude: 35.0, longitude: -98.0}
|
|
},
|
|
information_field: "test",
|
|
raw_packet: "POS-A>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "handles data_extended with nested 'position' map (string keys)" do
|
|
events = [
|
|
%{
|
|
sender: "POS-S",
|
|
base_callsign: "POS-S",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
data_extended: %{
|
|
"position" => %{"latitude" => 35.5, "longitude" => -98.5}
|
|
},
|
|
information_field: "test",
|
|
raw_packet: "POS-S>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "object event with :name atom-keyed binary in data_extended uses it" do
|
|
events = [
|
|
%{
|
|
sender: "OBJ-A",
|
|
base_callsign: "OBJ-A",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "object",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
data_extended: %{name: "MYATOMOBJ"},
|
|
information_field: "test",
|
|
raw_packet: "OBJ-A>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "object event with nil data_extended" do
|
|
events = [
|
|
%{
|
|
sender: "OBJ-NIL",
|
|
base_callsign: "OBJ-NIL",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "object",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
data_extended: nil,
|
|
information_field: ";NULL",
|
|
raw_packet: "OBJ-NIL>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "event with non-map data_extended (binary)" do
|
|
events = [
|
|
%{
|
|
sender: "BIN-1",
|
|
base_callsign: "BIN-1",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "position",
|
|
# Non-map data_extended → extract_position_from_data_extended(_) at line 676.
|
|
data_extended: "raw-binary",
|
|
information_field: "test",
|
|
raw_packet: "BIN-1>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "object event with 'name' string-keyed in data_extended uses it" do
|
|
events = [
|
|
%{
|
|
sender: "OBJ-S",
|
|
base_callsign: "OBJ-S",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "object",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
data_extended: %{"name" => "MYOBJSTR"},
|
|
information_field: "test",
|
|
raw_packet: "OBJ-S>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "object event with neither :name nor 'name' falls back to info_field regex" do
|
|
events = [
|
|
%{
|
|
sender: "OBJ-RG",
|
|
base_callsign: "OBJ-RG",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "object",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
data_extended: %{some: "junk"},
|
|
information_field: ";REGEXNAME*192200z3500.00N/07500.00W#Test",
|
|
data: %{"information_field" => ";REGEXNAME*192200z3500.00N/07500.00W#Test"},
|
|
raw_packet: "OBJ-RG>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
|
|
test "object event with data_extended that doesn't match falls through to nil" do
|
|
events = [
|
|
%{
|
|
sender: "OBJ-NN",
|
|
base_callsign: "OBJ-NN",
|
|
ssid: "0",
|
|
destination: "APRS",
|
|
path: "WIDE1-1",
|
|
data_type: "object",
|
|
lat: 33.0,
|
|
lon: -96.0,
|
|
data_extended: %{name: 123},
|
|
information_field: "no-prefix",
|
|
raw_packet: "OBJ-NN>APRS"
|
|
}
|
|
]
|
|
|
|
state = %{batch: [], batch_length: 0, batch_size: 1, batch_timeout: 1000, max_batch_size: 100, timer: nil}
|
|
{:noreply, [], _new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(50)
|
|
end
|
|
end
|
|
|
|
describe "handle_events/3 batch sizing" do
|
|
test "partial batch just accumulates and re-schedules" do
|
|
{:consumer, state, _} = PacketConsumer.init(subscribe_to: [])
|
|
Process.cancel_timer(state.timer)
|
|
state = %{state | timer: nil, batch_size: 100, max_batch_size: 1000}
|
|
|
|
events = [%{sender: "BATCH-1", raw_packet: "x", received_at: DateTime.utc_now()}]
|
|
|
|
assert {:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
assert new_state.batch_length == 1
|
|
assert length(new_state.batch) == 1
|
|
end
|
|
|
|
test "full batch is processed (flushes state)" do
|
|
{:consumer, state, _} = PacketConsumer.init(subscribe_to: [])
|
|
Process.cancel_timer(state.timer)
|
|
|
|
# batch_size=1 so a single event triggers full-batch handling.
|
|
state = %{state | timer: nil, batch_size: 1, max_batch_size: 1000}
|
|
|
|
events = [
|
|
%{
|
|
sender: "FULL-1",
|
|
base_callsign: "FULL",
|
|
ssid: "1",
|
|
raw_packet: "FULL-1>APRS:>",
|
|
received_at: DateTime.utc_now()
|
|
}
|
|
]
|
|
|
|
assert {:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
assert new_state.batch_length == 0
|
|
assert new_state.batch == []
|
|
Process.cancel_timer(new_state.timer)
|
|
end
|
|
|
|
test "oversized batch processes up to max and carries remainder" do
|
|
{:consumer, state, _} = PacketConsumer.init(subscribe_to: [])
|
|
Process.cancel_timer(state.timer)
|
|
|
|
# batch_size=100, max_batch_size=2, so a 3-event burst is "oversized".
|
|
state = %{state | timer: nil, batch_size: 100, max_batch_size: 2}
|
|
|
|
events =
|
|
for i <- 1..3 do
|
|
%{
|
|
sender: "OS-#{i}",
|
|
base_callsign: "OS",
|
|
ssid: to_string(i),
|
|
raw_packet: "OS-#{i}>APRS:>",
|
|
received_at: DateTime.utc_now()
|
|
}
|
|
end
|
|
|
|
assert {:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
# Carryover holds the remaining 1 packet past the max.
|
|
assert new_state.batch_length == 1
|
|
Process.cancel_timer(new_state.timer)
|
|
end
|
|
end
|
|
|
|
describe "handle_info(:process_batch, state)" do
|
|
test "empty batch just restarts the timer" do
|
|
{:consumer, state, _} = PacketConsumer.init(subscribe_to: [])
|
|
Process.cancel_timer(state.timer)
|
|
state = %{state | timer: nil}
|
|
|
|
assert {:noreply, [], new_state} = PacketConsumer.handle_info(:process_batch, state)
|
|
assert new_state.batch == []
|
|
assert new_state.batch_length == 0
|
|
assert is_reference(new_state.timer)
|
|
Process.cancel_timer(new_state.timer)
|
|
end
|
|
|
|
test "non-empty batch processes and then resets timer" do
|
|
{:consumer, state, _} = PacketConsumer.init(subscribe_to: [])
|
|
Process.cancel_timer(state.timer)
|
|
|
|
# A packet with minimal required fields. process_batch will try to
|
|
# insert — that may fail, but handle_info should still return :noreply.
|
|
packet = %{
|
|
sender: "UNIT-1",
|
|
base_callsign: "UNIT",
|
|
ssid: "1",
|
|
raw_packet: "UNIT-1>APRS:>test",
|
|
received_at: DateTime.utc_now()
|
|
}
|
|
|
|
state = %{state | batch: [packet], batch_length: 1, timer: nil}
|
|
|
|
assert {:noreply, [], new_state} = PacketConsumer.handle_info(:process_batch, state)
|
|
assert new_state.batch == []
|
|
assert new_state.batch_length == 0
|
|
assert is_reference(new_state.timer)
|
|
Process.cancel_timer(new_state.timer)
|
|
end
|
|
end
|
|
|
|
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")
|
|
# packet verified by subsequent field access
|
|
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")
|
|
# packet verified by subsequent field access
|
|
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")
|
|
# packet verified by subsequent field access
|
|
assert packet.object_name == "P-KILLED"
|
|
end
|
|
end
|
|
|
|
describe "handle_events/3 with stream processing" do
|
|
setup do
|
|
Repo.delete_all(Packet)
|
|
:ok
|
|
end
|
|
|
|
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 carries excess packets over to the next cycle" do
|
|
# Temporarily enable info level so we can see the carryover log line
|
|
Logger.configure(level: :info)
|
|
# Create more packets than max_batch_size
|
|
events =
|
|
for i <- 1..150 do
|
|
%{
|
|
sender: "K#{i}CARRY",
|
|
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 this cycle; 50 carry over
|
|
max_batch_size: 100,
|
|
timer: nil
|
|
}
|
|
|
|
# Capture logs to verify carryover notice
|
|
{new_state, log} =
|
|
with_log(fn ->
|
|
{:noreply, [], new_state} = PacketConsumer.handle_events(events, nil, state)
|
|
Process.sleep(20)
|
|
new_state
|
|
end)
|
|
|
|
# Should log that 50 packets carried over to the next cycle
|
|
assert log =~ "Carrying over 50 packets past batch-size limit to next cycle"
|
|
|
|
# The remaining 50 packets should still be in the consumer's batch,
|
|
# waiting for the next tick — they were not silently dropped.
|
|
assert new_state.batch_length == 50
|
|
assert length(new_state.batch) == 50
|
|
|
|
# Reset log level
|
|
Logger.configure(level: :error)
|
|
|
|
# Only max_batch_size packets should have been inserted this cycle
|
|
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 Map.has_key?(final_state, :batch)
|
|
|
|
# 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
|