defmodule Aprsme.Packets.QueryBuilderTest do use Aprsme.DataCase import Ecto.Query alias Aprsme.Packet alias Aprsme.Packets.QueryBuilder alias Aprsme.PacketsFixtures describe "within_bounds/2" do test "filters packets within normal bounds" do inside = PacketsFixtures.packet_fixture(%{ sender: "TEST-1", lat: Decimal.new("40.5"), lon: Decimal.new("-73.5") }) _outside = PacketsFixtures.packet_fixture(%{ sender: "TEST-2", lat: Decimal.new("50.0"), lon: Decimal.new("-73.5") }) # Test normal bounds [west, south, east, north] query = QueryBuilder.within_bounds(Packet, [-74.0, 40.0, -73.0, 41.0]) results = Aprsme.Repo.all(query) assert length(results) == 1 assert hd(results).id == inside.id end test "handles antimeridian crossing bounds" do west_side = PacketsFixtures.packet_fixture(%{ sender: "WEST-1", lat: Decimal.new("0.0"), lon: Decimal.new("175.0") }) east_side = PacketsFixtures.packet_fixture(%{ sender: "EAST-1", lat: Decimal.new("0.0"), lon: Decimal.new("-175.0") }) _middle = PacketsFixtures.packet_fixture(%{ sender: "MIDDLE-1", lat: Decimal.new("0.0"), lon: Decimal.new("0.0") }) # Test antimeridian crossing bounds [west=170, south=-10, east=-170, north=10] query = QueryBuilder.within_bounds(Packet, [170.0, -10.0, -170.0, 10.0]) results = Aprsme.Repo.all(query) assert length(results) == 2 result_ids = results |> Enum.map(& &1.id) |> Enum.sort() expected_ids = Enum.sort([west_side.id, east_side.id]) assert result_ids == expected_ids end test "returns query unchanged with invalid bounds" do original_query = from(p in Packet) # Test with nil assert QueryBuilder.within_bounds(original_query, nil) == original_query # Test with wrong number of elements assert QueryBuilder.within_bounds(original_query, [1, 2, 3]) == original_query # Test with non-numeric values assert QueryBuilder.within_bounds(original_query, ["a", "b", "c", "d"]) == original_query end end describe "weather_only/1" do test "filters to weather packets using has_weather column" do _weather = PacketsFixtures.packet_fixture(%{ sender: "WX-QBT", temperature: 72.0, has_weather: true }) _non_weather = PacketsFixtures.packet_fixture(%{ sender: "NOWX-QBT", comment: "No weather" }) query = QueryBuilder.weather_only(Packet) results = Aprsme.Repo.all(query) assert Enum.all?(results, fn p -> p.has_weather == true end) end end describe "with_time_range/2" do test "filters by start_time only" do now = DateTime.utc_now() _old = PacketsFixtures.packet_fixture(%{ sender: "TR-OLD", received_at: DateTime.add(now, -7200, :second) }) _recent = PacketsFixtures.packet_fixture(%{ sender: "TR-RECENT", received_at: DateTime.add(now, -60, :second) }) start_time = DateTime.add(now, -3600, :second) query = QueryBuilder.with_time_range(Packet, start_time: start_time) results = Aprsme.Repo.all(query) callsigns = Enum.map(results, & &1.sender) assert "TR-RECENT" in callsigns refute "TR-OLD" in callsigns end test "filters by end_time only" do now = DateTime.utc_now() _future = PacketsFixtures.packet_fixture(%{ sender: "TR-FUT", received_at: DateTime.add(now, 3600, :second) }) _past = PacketsFixtures.packet_fixture(%{ sender: "TR-PAST", received_at: DateTime.add(now, -60, :second) }) query = QueryBuilder.with_time_range(Packet, end_time: now) results = Aprsme.Repo.all(query) callsigns = Enum.map(results, & &1.sender) assert "TR-PAST" in callsigns refute "TR-FUT" in callsigns end test "accepts options as keyword list or map" do start_time = DateTime.add(DateTime.utc_now(), -3600, :second) q1 = QueryBuilder.with_time_range(Packet, start_time: start_time) q2 = QueryBuilder.with_time_range(Packet, %{start_time: start_time}) # Both should produce semantically equivalent queries. assert inspect(q1) == inspect(q2) end test "accepts string keys" do start_time = DateTime.add(DateTime.utc_now(), -3600, :second) query = QueryBuilder.with_time_range(Packet, %{"start_time" => start_time}) # Should run without raising. assert Aprsme.Repo.all(query) end end describe "with_position/1" do test "filters to packets with has_position == true" do _positioned = PacketsFixtures.packet_fixture(%{ sender: "POS-QBT", has_position: true }) query = QueryBuilder.with_position(Packet) results = Aprsme.Repo.all(query) assert Enum.all?(results, fn p -> p.has_position == true end) end end describe "for_callsign/2 and for_base_callsign/2" do test "matches callsign case-insensitively" do _p = PacketsFixtures.packet_fixture(%{ sender: "FCS-1", base_callsign: "FCS" }) # Case-insensitive match via upper(). query = QueryBuilder.for_callsign(Packet, "fcs-1") results = Aprsme.Repo.all(query) assert Enum.any?(results, &(&1.sender == "FCS-1")) end test "filters by base callsign exactly" do _p = PacketsFixtures.packet_fixture(%{ sender: "BASECS-9", base_callsign: "BASECS" }) query = QueryBuilder.for_base_callsign(Packet, "BASECS") results = Aprsme.Repo.all(query) assert Enum.any?(results, &(&1.base_callsign == "BASECS")) end end describe "ordering helpers" do test "recent_first/1 orders newest first" do now = DateTime.utc_now() _a = PacketsFixtures.packet_fixture(%{ sender: "ORDA-1", received_at: DateTime.add(now, -600, :second) }) _b = PacketsFixtures.packet_fixture(%{ sender: "ORDB-2", received_at: now }) query = Packet |> QueryBuilder.for_base_callsign("ORDB") |> QueryBuilder.recent_first() assert Aprsme.Repo.all(query) != [] end test "chronological/1 orders oldest first" do query = QueryBuilder.chronological(Packet) # Just verifies the query composes without raising. assert query |> limit(10) |> Aprsme.Repo.all() end end describe "recent_position_packets/1" do test "returns a list without raising" do assert Aprsme.Repo.all(QueryBuilder.recent_position_packets()) end test "accepts keyword list opts" do assert Aprsme.Repo.all(QueryBuilder.recent_position_packets(limit: 10, hours_back: 1)) end test "accepts map opts with string keys" do assert Aprsme.Repo.all(QueryBuilder.recent_position_packets(%{"limit" => 10, "hours_back" => 1})) end end describe "callsign_history/2" do test "returns a list without raising" do assert Aprsme.Repo.all(QueryBuilder.callsign_history("TEST-1")) end test "supports hours_back in map form" do assert Aprsme.Repo.all(QueryBuilder.callsign_history("TEST-1", %{hours_back: 2})) end end describe "weather_packets/1" do test "returns a list without raising" do assert Aprsme.Repo.all(QueryBuilder.weather_packets()) end test "supports callsign filter" do assert Aprsme.Repo.all(QueryBuilder.weather_packets(%{callsign: "WXHELPER"})) end test "supports string callsign filter" do assert Aprsme.Repo.all(QueryBuilder.weather_packets(%{"callsign" => "WXHELPER"})) end end describe "maybe_filter_region/2" do test "keyword opts without region is a no-op" do query = QueryBuilder.maybe_filter_region(Packet, limit: 10) assert query |> limit(5) |> Aprsme.Repo.all() end test "map opts with nil region is a no-op" do query = QueryBuilder.maybe_filter_region(Packet, %{region: nil}) assert query |> limit(5) |> Aprsme.Repo.all() end end describe "select_map_fields/1" do test "returns only the subset of fields used for map rendering" do query = QueryBuilder.select_map_fields(Packet) results = query |> limit(3) |> Aprsme.Repo.all() assert Enum.to_list(results) == results end end end