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 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 is_binary(station.callsign) assert is_binary(station.base_callsign) assert is_float(station.lat) assert is_float(station.lon) assert is_float(station.distance_miles) assert is_binary(station.symbol_table_id) assert is_binary(station.symbol_code) assert is_binary(station.comment) assert %DateTime{} = station.received_at # Weather fields (may be nil) assert is_nil(station.temperature) or is_float(station.temperature) assert is_nil(station.humidity) or is_float(station.humidity) assert is_nil(station.pressure) or is_float(station.pressure) assert is_nil(station.wind_speed) or is_float(station.wind_speed) assert is_nil(station.wind_direction) or is_integer(station.wind_direction) assert is_nil(station.wind_gust) or is_float(station.wind_gust) assert is_nil(station.rain_1h) or is_float(station.rain_1h) assert is_nil(station.rain_24h) or is_float(station.rain_24h) assert is_nil(station.rain_since_midnight) or is_float(station.rain_since_midnight) 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 end