defmodule Aprsme.Packets.PreparedQueriesTest do use Aprsme.DataCase, async: false alias Aprsme.Packet alias Aprsme.Packets.PreparedQueries alias Aprsme.Repo defp create_positioned_packet(attrs) do defaults = %{ sender: "TEST-1", base_callsign: "TEST", ssid: "1", destination: "APRS", data_type: "position", lat: Decimal.new("33.0000"), lon: Decimal.new("-96.0000"), received_at: DateTime.truncate(DateTime.utc_now(), :second) } merged = Map.merge(defaults, attrs) %Packet{} |> Packet.changeset(Map.from_struct(Map.merge(%Packet{}, merged))) |> Repo.insert() end describe "get_latest_packets_for_callsigns/1" do test "returns full packet structs for multiple callsigns" do {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000"), comment: "Digi TX" }) {:ok, _} = create_positioned_packet(%{ sender: "N5TXZ-10", base_callsign: "N5TXZ", ssid: "10", lat: Decimal.new("33.2000"), lon: Decimal.new("-96.5000"), comment: "iGate" }) result = PreparedQueries.get_latest_packets_for_callsigns(["K5GVL-10", "N5TXZ-10"]) assert length(result) == 2 assert Enum.all?(result, &is_struct(&1, Packet)) senders = Enum.map(result, & &1.sender) assert "K5GVL-10" in senders assert "N5TXZ-10" in senders k5gvl = Enum.find(result, &(&1.sender == "K5GVL-10")) assert_in_delta k5gvl.lat, 33.1, 0.01 assert_in_delta k5gvl.lon, -96.6, 0.01 end test "returns empty list for empty input" do assert PreparedQueries.get_latest_packets_for_callsigns([]) == [] end test "returns empty list for nonexistent callsigns" do result = PreparedQueries.get_latest_packets_for_callsigns(["NONEXIST-1", "FAKE-2"]) assert result == [] end test "returns latest packet when multiple exist for a callsign" do {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000"), received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("34.0000"), lon: Decimal.new("-97.0000"), received_at: DateTime.truncate(DateTime.utc_now(), :second) }) result = PreparedQueries.get_latest_packets_for_callsigns(["K5GVL-10"]) assert length(result) == 1 packet = hd(result) assert is_struct(packet, Packet) assert_in_delta packet.lat, 34.0, 0.01 assert_in_delta packet.lon, -97.0, 0.01 end end describe "get_latest_packet_for_callsign/1" do test "prefers the latest positioned packet over a newer non-position packet" do positioned_received_at = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second) status_received_at = DateTime.truncate(DateTime.utc_now(), :second) {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000"), received_at: positioned_received_at }) {:ok, _} = %Packet{} |> Packet.changeset(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", destination: "APRS", data_type: "status", received_at: status_received_at, has_position: false, raw_packet: "K5GVL-10>APRS:>status update" }) |> Repo.insert() packet = PreparedQueries.get_latest_packet_for_callsign("K5GVL-10") assert packet.has_position == true assert_in_delta packet.lat, 33.1, 0.01 assert_in_delta packet.lon, -96.6, 0.01 end test "falls back to the latest packet when no positioned packet exists" do received_at = DateTime.truncate(DateTime.utc_now(), :second) {:ok, _} = %Packet{} |> Packet.changeset(%{ sender: "STATUS-ONLY", base_callsign: "STATUS", ssid: "0", destination: "APRS", data_type: "status", received_at: received_at, has_position: false, raw_packet: "STATUS-ONLY>APRS:>status update" }) |> Repo.insert() packet = PreparedQueries.get_latest_packet_for_callsign("STATUS-ONLY") assert packet.sender == "STATUS-ONLY" assert packet.has_position == false end test "ignores packets older than the retention window" do # Insert a packet whose received_at is way beyond any reasonable retention # window — older than the configured retention_days plus a margin. The # query should not return it, so partition pruning can skip those # partitions on the live table. retention_days = Application.get_env(:aprsme, :packet_retention_days, 7) ancient = DateTime.add(DateTime.utc_now(), -(retention_days + 30) * 86_400, :second) {:ok, _} = create_positioned_packet(%{ sender: "ANCIENT-1", base_callsign: "ANCIENT", ssid: "1", received_at: DateTime.truncate(ancient, :second) }) assert is_nil(PreparedQueries.get_latest_packet_for_callsign("ANCIENT-1")) end end describe "get_nearby_stations_knn/4" do test "returns stations sorted by distance from the point" do # Close to center {:ok, _} = create_positioned_packet(%{ sender: "NEAR-1", base_callsign: "NEAR", ssid: "1", lat: Decimal.new("33.0100"), lon: Decimal.new("-96.0100") }) # Further {:ok, _} = create_positioned_packet(%{ sender: "FAR-1", base_callsign: "FAR", ssid: "1", lat: Decimal.new("33.5000"), lon: Decimal.new("-96.5000") }) result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, nil, %{limit: 5}) callsigns = Enum.map(result, & &1.callsign) assert "NEAR-1" in callsigns assert "FAR-1" in callsigns # NEAR-1 must come before FAR-1 (sorted by distance) assert Enum.find_index(result, &(&1.callsign == "NEAR-1")) < Enum.find_index(result, &(&1.callsign == "FAR-1")) end test "excludes the specified callsign" do {:ok, _} = create_positioned_packet(%{ sender: "ME-1", base_callsign: "ME", ssid: "1", lat: Decimal.new("33.0100"), lon: Decimal.new("-96.0100") }) {:ok, _} = create_positioned_packet(%{ sender: "OTHER-1", base_callsign: "OTHER", ssid: "1", lat: Decimal.new("33.0200"), lon: Decimal.new("-96.0200") }) result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, "ME-1", %{limit: 5}) callsigns = Enum.map(result, & &1.callsign) refute "ME-1" in callsigns assert "OTHER-1" in callsigns end test "honors :max_radius_km option" do # Within 10km of (33.0, -96.0): ~1km offset {:ok, _} = create_positioned_packet(%{ sender: "INSIDE-1", base_callsign: "INSIDE", ssid: "1", lat: Decimal.new("33.0100"), lon: Decimal.new("-96.0100") }) # Outside 10km: ~50km away {:ok, _} = create_positioned_packet(%{ sender: "OUTSIDE-1", base_callsign: "OUTSIDE", ssid: "1", lat: Decimal.new("33.5000"), lon: Decimal.new("-96.5000") }) result = PreparedQueries.get_nearby_stations_knn(33.0, -96.0, nil, %{limit: 10, max_radius_km: 10}) callsigns = Enum.map(result, & &1.callsign) assert "INSIDE-1" in callsigns refute "OUTSIDE-1" in callsigns end end describe "get_latest_positions_for_callsigns/1" do test "returns positions for multiple callsigns" do {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000") }) {:ok, _} = create_positioned_packet(%{ sender: "N5TXZ-10", base_callsign: "N5TXZ", ssid: "10", lat: Decimal.new("33.2000"), lon: Decimal.new("-96.5000") }) result = PreparedQueries.get_latest_positions_for_callsigns(["K5GVL-10", "N5TXZ-10"]) assert length(result) == 2 callsigns = Enum.map(result, & &1.callsign) assert "K5GVL-10" in callsigns assert "N5TXZ-10" in callsigns k5gvl = Enum.find(result, &(&1.callsign == "K5GVL-10")) assert_in_delta k5gvl.lat, 33.1, 0.01 assert_in_delta k5gvl.lng, -96.6, 0.01 end test "returns empty list for empty input" do assert PreparedQueries.get_latest_positions_for_callsigns([]) == [] end test "returns empty list for nonexistent callsigns" do result = PreparedQueries.get_latest_positions_for_callsigns(["NONEXIST-1", "FAKE-2"]) assert result == [] end test "is case-insensitive" do {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000") }) result = PreparedQueries.get_latest_positions_for_callsigns(["k5gvl-10"]) assert length(result) == 1 assert hd(result).callsign == "K5GVL-10" end test "returns latest position when multiple packets exist for a callsign" do {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("33.1000"), lon: Decimal.new("-96.6000"), received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) {:ok, _} = create_positioned_packet(%{ sender: "K5GVL-10", base_callsign: "K5GVL", ssid: "10", lat: Decimal.new("34.0000"), lon: Decimal.new("-97.0000"), received_at: DateTime.truncate(DateTime.utc_now(), :second) }) result = PreparedQueries.get_latest_positions_for_callsigns(["K5GVL-10"]) assert length(result) == 1 position = hd(result) assert_in_delta position.lat, 34.0, 0.01 assert_in_delta position.lng, -97.0, 0.01 end end describe "get_nearby_weather_stations/4" do setup do # San Francisco center: 37.7749, -122.4194 center_lat = 37.7749 center_lon = -122.4194 # Station A: ~0.5 miles north, weather 1h ago (should be included) {:ok, station_a} = create_positioned_packet(%{ sender: "WX-A", base_callsign: "WX-A", ssid: "0", lat: Decimal.new("37.7822"), lon: Decimal.new("-122.4194"), temperature: 72.5, humidity: 65.0, pressure: 1013.25, wind_speed: 5.0, wind_direction: 180, symbol_table_id: "/", symbol_code: "_", comment: "Weather Station A", received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) # Station B: ~10 miles east, weather 2h ago (should be included) {:ok, station_b} = create_positioned_packet(%{ sender: "WX-B-1", base_callsign: "WX-B", ssid: "1", lat: Decimal.new("37.7749"), lon: Decimal.new("-122.2700"), temperature: 68.0, humidity: 70.0, wind_gust: 12.0, symbol_table_id: "/", symbol_code: "_", comment: "Weather Station B", received_at: DateTime.add(DateTime.utc_now(), -7200, :second) }) # Station C: ~50 miles south, weather 1h ago (should be excluded - outside radius) {:ok, _station_c} = create_positioned_packet(%{ sender: "WX-C", base_callsign: "WX-C", ssid: "0", lat: Decimal.new("37.0500"), lon: Decimal.new("-122.4194"), temperature: 75.0, humidity: 60.0, symbol_table_id: "/", symbol_code: "_", comment: "Weather Station C", received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) # Station D: ~5 miles west, weather 8h ago (should be excluded - outside time window) {:ok, _station_d} = create_positioned_packet(%{ sender: "WX-D", base_callsign: "WX-D", ssid: "0", lat: Decimal.new("37.7749"), lon: Decimal.new("-122.4900"), temperature: 70.0, humidity: 68.0, symbol_table_id: "/", symbol_code: "_", comment: "Weather Station D", received_at: DateTime.add(DateTime.utc_now(), -28_800, :second) }) # Station E: ~5 miles southeast, no weather data (should be excluded) {:ok, _station_e} = create_positioned_packet(%{ sender: "WX-E", base_callsign: "WX-E", ssid: "0", lat: Decimal.new("37.7400"), lon: Decimal.new("-122.3800"), symbol_table_id: "/", symbol_code: "-", comment: "Regular Station", received_at: DateTime.add(DateTime.utc_now(), -3600, :second) }) # Duplicate SSID for Station B (should be deduplicated) {:ok, _station_b2} = create_positioned_packet(%{ sender: "WX-B-2", base_callsign: "WX-B", ssid: "2", lat: Decimal.new("37.7749"), lon: Decimal.new("-122.2700"), temperature: 69.0, humidity: 72.0, symbol_table_id: "/", symbol_code: "_", comment: "Weather Station B SSID 2", received_at: DateTime.add(DateTime.utc_now(), -1800, :second) }) %{ center_lat: center_lat, center_lon: center_lon, station_a: station_a, station_b: station_b } end test "returns nearby weather stations within radius and time window", %{ center_lat: lat, center_lon: lon } do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0) assert length(result) == 2 callsigns = Enum.map(result, & &1.callsign) assert "WX-A" in callsigns assert "WX-B-2" in callsigns end test "returns results ordered by distance (closest first)", %{center_lat: lat, center_lon: lon} do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0) assert length(result) == 2 # Station A (~0.5 miles) should be first assert hd(result).callsign == "WX-A" assert hd(result).distance_miles < 1.0 # Station B (~8-10 miles) should be second second = Enum.at(result, 1) assert second.callsign == "WX-B-2" assert second.distance_miles > 7.0 assert second.distance_miles < 10.0 end test "excludes stations outside radius", %{center_lat: lat, center_lon: lon} do # Use 5 mile radius - should only get Station A result = PreparedQueries.get_nearby_weather_stations(lat, lon, 5.0) assert length(result) == 1 assert hd(result).callsign == "WX-A" end test "excludes stations outside time window", %{center_lat: lat, center_lon: lon} do # Use 1 hour window - should get both A (1h ago) and B-2 (30min ago) result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 1) assert length(result) == 2 callsigns = Enum.map(result, & &1.callsign) assert "WX-A" in callsigns assert "WX-B-2" in callsigns end test "excludes stations without weather data", %{center_lat: lat, center_lon: lon} do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0) callsigns = Enum.map(result, & &1.callsign) refute "WX-E" in callsigns end test "deduplicates by base_callsign and returns most recent", %{ center_lat: lat, center_lon: lon } do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0) base_callsigns = Enum.map(result, & &1.base_callsign) assert length(base_callsigns) == length(Enum.uniq(base_callsigns)) # Should get WX-B-2 (most recent) not WX-B-1 wx_b = Enum.find(result, &(&1.base_callsign == "WX-B")) assert wx_b.callsign == "WX-B-2" end test "respects limit option", %{center_lat: lat, center_lon: lon} do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, limit: 1) assert length(result) == 1 assert hd(result).callsign == "WX-A" end test "returns all required fields", %{center_lat: lat, center_lon: lon} do result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0) station = hd(result) assert byte_size(station.callsign) > 0 assert byte_size(station.base_callsign) > 0 assert is_float(station.lat) assert is_float(station.lon) assert is_float(station.distance_miles) assert byte_size(station.symbol_table_id) > 0 assert byte_size(station.symbol_code) > 0 assert byte_size(station.comment) > 0 assert %DateTime{} = station.received_at # Weather fields (may be nil) case station.temperature do nil -> :ok value when is_float(value) -> :ok end case station.humidity do nil -> :ok value when is_float(value) -> :ok end case station.pressure do nil -> :ok value when is_float(value) -> :ok end case station.wind_speed do nil -> :ok value when is_float(value) -> :ok end case station.wind_direction do nil -> :ok value when is_integer(value) -> :ok end case station.wind_gust do nil -> :ok value when is_float(value) -> :ok end case station.rain_1h do nil -> :ok value when is_float(value) -> :ok end case station.rain_24h do nil -> :ok value when is_float(value) -> :ok end case station.rain_since_midnight do nil -> :ok value when is_float(value) -> :ok end end test "returns empty list when no stations in radius", %{center_lat: _lat, center_lon: _lon} do # Use coordinates far from any station result = PreparedQueries.get_nearby_weather_stations(40.0, -100.0, 5.0) assert result == [] end test "handles custom hours option", %{center_lat: lat, center_lon: lon} do # Use 3 hour window - should get both A and B result = PreparedQueries.get_nearby_weather_stations(lat, lon, 15.0, hours: 3) assert length(result) == 2 end end describe "get_recent_packets_in_bounds/2" do test "returns packets within the bounding box" do {:ok, _} = create_positioned_packet(%{ sender: "INBOUNDS-1", base_callsign: "INBOUNDS", lat: Decimal.new("33.5"), lon: Decimal.new("-96.5") }) {:ok, _} = create_positioned_packet(%{ sender: "OUTOFBOUNDS-1", base_callsign: "OUTOFBOUNDS", lat: Decimal.new("50.0"), lon: Decimal.new("-120.0") }) # bounds = [north, south, east, west] result = PreparedQueries.get_recent_packets_in_bounds([34.0, 33.0, -96.0, -97.0], 24) senders = Enum.map(result, & &1.sender) assert "INBOUNDS-1" in senders refute "OUTOFBOUNDS-1" in senders end end describe "get_packet_count_in_area/2" do test "counts only packets inside the bounding box" do {:ok, _} = create_positioned_packet(%{ sender: "COUNT-IN-1", base_callsign: "COUNT", lat: Decimal.new("33.25"), lon: Decimal.new("-96.25") }) {:ok, _} = create_positioned_packet(%{ sender: "COUNT-OUT", base_callsign: "COUNT", lat: Decimal.new("55.0"), lon: Decimal.new("-30.0") }) # bounds = [north, south, east, west] count = PreparedQueries.get_packet_count_in_area([34.0, 33.0, -96.0, -97.0], 24) assert is_integer(count) assert count >= 1 end test "returns 0 when area has no packets" do assert PreparedQueries.get_packet_count_in_area([0.0, -1.0, 180.0, 179.0], 1) == 0 end end describe "has_weather_packets?/1" do test "returns true when callsign has weather packets" do {:ok, _} = create_positioned_packet(%{ sender: "HASWX-1", base_callsign: "HASWX", temperature: 72.0, humidity: 50.0 }) assert PreparedQueries.has_weather_packets?("HASWX-1") end test "returns false when callsign has no weather packets" do refute PreparedQueries.has_weather_packets?("NOWX-NEVER") end end describe "weather_callsigns/1" do test "returns a MapSet containing callsigns with weather data" do {:ok, _} = create_positioned_packet(%{ sender: "WXSET-1", base_callsign: "WXSET", has_weather: true, temperature: 65.0 }) result = PreparedQueries.weather_callsigns(["WXSET-1", "NOWX-1"]) assert %MapSet{} = result assert MapSet.member?(result, "WXSET-1") refute MapSet.member?(result, "NOWX-1") end test "returns an empty MapSet for empty input" do assert PreparedQueries.weather_callsigns([]) == MapSet.new() end end end