diff --git a/config/config.exs b/config/config.exs index e01594d6..5dc4d56e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -17,16 +17,16 @@ config :esbuild, env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} ] -# Configure Elixir's Logger -config :logger, :default_formatter, - format: "$time $metadata[$level] $message\n", - metadata: [:request_id, :remote_ip] - config :live_table, app: :microwaveprop, repo: Microwaveprop.Repo, pubsub: Microwaveprop.PubSub +# Configure Elixir's Logger +config :logger, :default_formatter, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id, :remote_ip] + # Configure the mailer # # By default it uses the "Local" adapter which stores the emails diff --git a/config/runtime.exs b/config/runtime.exs index 18783d46..1a7f42a1 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -22,6 +22,17 @@ end config :microwaveprop, MicrowavepropWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] +if qrz_username = System.get_env("QRZ_USERNAME") do + config :microwaveprop, Microwaveprop.Qrz.Client, + username: qrz_username, + password: System.get_env("QRZ_PASSWORD") || raise("QRZ_PASSWORD missing"), + agent: System.get_env("QRZ_AGENT", "microwaveprop/1.0") +end + +if google_api_key = System.get_env("GOOGLE_API_KEY") do + config :microwaveprop, Microwaveprop.Geocoder, api_key: google_api_key +end + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || diff --git a/config/test.exs b/config/test.exs index 423cb496..80c27bc3 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,13 +1,27 @@ import Config +alias Microwaveprop.Qrz.Client + # Only in tests, remove the complexity from the password hashing algorithm config :bcrypt_elixir, :log_rounds, 1 # Print only warnings and errors during test config :logger, level: :warning +config :microwaveprop, Client, + username: "testuser", + password: "testpass", + agent: "microwaveprop-test/1.0", + plug: {Req.Test, Client}, + retry: false + +config :microwaveprop, Microwaveprop.Geocoder, + plug: {Req.Test, Microwaveprop.Geocoder}, + retry: false + # In test we don't send emails config :microwaveprop, Microwaveprop.Mailer, adapter: Swoosh.Adapters.Test +config :microwaveprop, Microwaveprop.Qrz, cache_ttl_hours: 0 # Configure your database # diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index 1e79cf78..ee087d30 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -19,6 +19,7 @@ defmodule Microwaveprop.Application do Microwaveprop.Weather.GridCache, Microwaveprop.Weather.MrmsCache, Microwaveprop.Weather.NexradCache, + Microwaveprop.Qrz.Client, {Oban, Application.fetch_env!(:microwaveprop, Oban)}, Microwaveprop.RepoListener, # Start to serve requests, typically the last entry diff --git a/lib/microwaveprop/callsign_location.ex b/lib/microwaveprop/callsign_location.ex new file mode 100644 index 00000000..b085cae5 --- /dev/null +++ b/lib/microwaveprop/callsign_location.ex @@ -0,0 +1,125 @@ +defmodule Microwaveprop.CallsignLocation do + @moduledoc """ + Resolves a callsign to a lat/lon + Maidenhead grid by combining a + QRZ.com lookup (licensee address) with the Google Maps geocoder, + caching the result in `callsign_locations`. + + If QRZ already has ``/`` fields for the callsign, those are + used directly and the geocoder is skipped. Otherwise the QRZ address + is joined into a single line and sent to Google's geocoding endpoint. + """ + alias Microwaveprop.CallsignLocation.Location + alias Microwaveprop.Geocoder + alias Microwaveprop.Qrz + alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Repo + + @type result :: %{ + callsign: String.t(), + latitude: float(), + longitude: float(), + gridsquare: String.t(), + name: String.t() | nil, + address: String.t() | nil + } + + @spec lookup(String.t()) :: {:ok, result()} | {:error, String.t()} + def lookup(callsign) do + callsign = String.upcase(callsign) + + case Repo.get_by(Location, callsign: callsign) do + %Location{} = cached -> + {:ok, format_cached(cached)} + + nil -> + fetch_and_cache(callsign) + end + end + + defp fetch_and_cache(callsign) do + with {:ok, record} <- Qrz.lookup_callsign(callsign), + {:ok, %{lat: lat, lon: lon}, address} <- resolve_coordinates(record) do + gridsquare = Maidenhead.from_latlon(lat, lon, 8) + name = build_name(record) + + {:ok, _} = + %Location{} + |> Location.changeset(%{ + callsign: callsign, + latitude: lat, + longitude: lon, + gridsquare: gridsquare + }) + |> Repo.insert( + on_conflict: [ + set: [ + latitude: lat, + longitude: lon, + gridsquare: gridsquare, + updated_at: DateTime.utc_now(:second) + ] + ], + conflict_target: :callsign, + returning: true + ) + + {:ok, + %{ + callsign: callsign, + latitude: lat, + longitude: lon, + gridsquare: gridsquare, + name: name, + address: address + }} + end + end + + defp resolve_coordinates(record) do + address = build_address_string(record) + + case {record.lat, record.lon} do + {lat, lon} when is_float(lat) and is_float(lon) -> + {:ok, %{lat: lat, lon: lon}, address} + + _ -> + with {:ok, addr} <- require_address(address), + {:ok, coords} <- Geocoder.geocode(addr) do + {:ok, coords, addr} + end + end + end + + defp build_address_string(record) do + parts = + Enum.reject( + [record.addr1, record.addr2, record.state, record.zip, record.country], + &(is_nil(&1) or String.trim(&1) == "") + ) + + if parts == [], do: nil, else: Enum.join(parts, ", ") + end + + defp require_address(nil), do: {:error, "No address available for callsign"} + defp require_address(address), do: {:ok, address} + + defp build_name(record) do + joined = + [record.fname, record.name] + |> Enum.reject(&is_nil/1) + |> Enum.join(" ") + + if joined == "", do: nil, else: joined + end + + defp format_cached(%Location{} = loc) do + %{ + callsign: loc.callsign, + latitude: loc.latitude, + longitude: loc.longitude, + gridsquare: loc.gridsquare, + name: nil, + address: nil + } + end +end diff --git a/lib/microwaveprop/callsign_location/location.ex b/lib/microwaveprop/callsign_location/location.ex new file mode 100644 index 00000000..5bf6bf39 --- /dev/null +++ b/lib/microwaveprop/callsign_location/location.ex @@ -0,0 +1,27 @@ +defmodule Microwaveprop.CallsignLocation.Location do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + schema "callsign_locations" do + field :callsign, :string + field :latitude, :float + field :longitude, :float + field :gridsquare, :string + + timestamps(type: :utc_datetime) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(location, attrs) do + location + |> cast(attrs, [:callsign, :latitude, :longitude, :gridsquare]) + |> validate_required([:callsign, :latitude, :longitude, :gridsquare]) + |> update_change(:callsign, &String.upcase/1) + |> unique_constraint(:callsign) + end +end diff --git a/lib/microwaveprop/geocoder.ex b/lib/microwaveprop/geocoder.ex new file mode 100644 index 00000000..5fb0338e --- /dev/null +++ b/lib/microwaveprop/geocoder.ex @@ -0,0 +1,40 @@ +defmodule Microwaveprop.Geocoder do + @moduledoc """ + Thin wrapper around the Google Maps Geocoding API. Only used as a + fallback when QRZ's own `` / `` fields are missing on a + callsign record. + """ + + @spec geocode(String.t()) :: {:ok, %{lat: float(), lon: float()}} | {:error, String.t()} + def geocode(address) do + case Req.get(client(), url: "/maps/api/geocode/json", params: [address: address, key: api_key()]) do + {:ok, %{status: 200, body: %{"status" => "OK", "results" => [first | _]}}} -> + %{"geometry" => %{"location" => %{"lat" => lat, "lng" => lon}}} = first + {:ok, %{lat: lat, lon: lon}} + + {:ok, %{status: 200, body: %{"status" => "ZERO_RESULTS"}}} -> + {:error, "No results found"} + + {:ok, %{status: 200, body: %{"status" => status}}} -> + {:error, "Geocoding failed: #{status}"} + + {:error, reason} -> + {:error, "Geocoding request failed: #{inspect(reason)}"} + end + end + + defp client do + config = Application.get_env(:microwaveprop, __MODULE__, []) + + [base_url: "https://maps.googleapis.com"] + |> Req.new() + |> Req.Request.register_options([:plug, :api_key]) + |> Req.merge(config) + end + + defp api_key do + :microwaveprop + |> Application.get_env(__MODULE__, []) + |> Keyword.get(:api_key, "") + end +end diff --git a/lib/microwaveprop/qrz.ex b/lib/microwaveprop/qrz.ex new file mode 100644 index 00000000..e4868ef5 --- /dev/null +++ b/lib/microwaveprop/qrz.ex @@ -0,0 +1,72 @@ +defmodule Microwaveprop.Qrz do + @moduledoc """ + Callsign-lookup facade around the QRZ.com XML API. + + Results are cached in the `qrz_callsigns` table keyed by uppercased + callsign with a configurable TTL (`cache_ttl_hours`, default 168 / 7 + days). Entries that are still fresh return from the DB without + touching the upstream. + """ + import Ecto.Query + + alias Microwaveprop.Qrz.Callsign + alias Microwaveprop.Qrz.Client + alias Microwaveprop.Qrz.Record + alias Microwaveprop.Repo + + @spec lookup_callsign(String.t()) :: {:ok, Record.t()} | {:error, String.t()} + def lookup_callsign(callsign) do + callsign = String.upcase(callsign) + + case get_fresh_cache(callsign) do + %Callsign{data: data} -> + {:ok, Record.from_map(data)} + + nil -> + fetch_and_upsert(callsign) + end + end + + @spec get_callsign(String.t()) :: Callsign.t() | nil + def get_callsign(callsign) do + callsign = String.upcase(callsign) + Repo.get_by(Callsign, callsign: callsign) + end + + defp get_fresh_cache(callsign) do + ttl_hours = config()[:cache_ttl_hours] || 168 + cutoff = DateTime.add(DateTime.utc_now(), -ttl_hours, :hour) + + Callsign + |> where([c], c.callsign == ^callsign and c.updated_at > ^cutoff) + |> Repo.one() + end + + defp fetch_and_upsert(callsign) do + case Client.lookup(callsign) do + {:ok, data} -> + now = DateTime.utc_now(:second) + + result = + %Callsign{} + |> Callsign.changeset(%{callsign: callsign, data: data}) + |> Repo.insert( + on_conflict: [set: [data: data, updated_at: now]], + conflict_target: :callsign, + returning: true + ) + + case result do + {:ok, %Callsign{data: data}} -> {:ok, Record.from_map(data)} + {:error, changeset} -> {:error, "Failed to save: #{inspect(changeset.errors)}"} + end + + {:error, _} = error -> + error + end + end + + defp config do + Application.get_env(:microwaveprop, __MODULE__, []) + end +end diff --git a/lib/microwaveprop/qrz/callsign.ex b/lib/microwaveprop/qrz/callsign.ex new file mode 100644 index 00000000..7c517958 --- /dev/null +++ b/lib/microwaveprop/qrz/callsign.ex @@ -0,0 +1,24 @@ +defmodule Microwaveprop.Qrz.Callsign do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + schema "qrz_callsigns" do + field :callsign, :string + field :data, :map + timestamps(type: :utc_datetime) + end + + @spec changeset(%__MODULE__{}, map()) :: Ecto.Changeset.t() + def changeset(callsign, attrs) do + callsign + |> cast(attrs, [:callsign, :data]) + |> validate_required([:callsign, :data]) + |> update_change(:callsign, &String.upcase/1) + |> unique_constraint(:callsign) + end +end diff --git a/lib/microwaveprop/qrz/client.ex b/lib/microwaveprop/qrz/client.ex new file mode 100644 index 00000000..d5e8c01e --- /dev/null +++ b/lib/microwaveprop/qrz/client.ex @@ -0,0 +1,189 @@ +defmodule Microwaveprop.Qrz.Client do + @moduledoc """ + HTTP client for the QRZ.com XML callsign API. + + Maintains a session key in an `Agent` and transparently re-logs-in + when the upstream returns a `session_expired` response. The session + key is cached process-local because QRZ rate-limits login calls. + """ + use Agent + + require Record + + Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl")) + Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl")) + + @url "https://xmldata.qrz.com/xml/current/" + + @spec start_link(keyword()) :: Agent.on_start() + def start_link(_) do + Agent.start_link(fn -> nil end, name: __MODULE__) + end + + @spec reset_session() :: :ok + def reset_session do + Agent.update(__MODULE__, fn _ -> nil end) + end + + @spec login() :: {:ok, String.t()} | {:error, String.t()} + def login do + config = Application.get_env(:microwaveprop, __MODULE__, []) + username = Keyword.fetch!(config, :username) + password = Keyword.fetch!(config, :password) + agent = Keyword.get(config, :agent, "microwaveprop/1.0") + + params = %{username: username, password: password, agent: agent} + + with {:ok, xml} <- do_request(params), + {:ok, key} <- parse_session(xml) do + Agent.update(__MODULE__, fn _ -> key end) + {:ok, key} + end + end + + @spec lookup(String.t()) :: {:ok, map()} | {:error, String.t()} + def lookup(callsign) do + with {:ok, key} <- ensure_session() do + do_lookup(callsign, key, true) + end + end + + defp ensure_session do + case Agent.get(__MODULE__, & &1) do + nil -> login() + key -> {:ok, key} + end + end + + defp do_lookup(callsign, key, retry?) do + params = %{s: key, callsign: callsign} + + with {:ok, xml} <- do_request(params) do + xml + |> parse_callsign_response() + |> handle_lookup_result(callsign, retry?) + end + end + + defp handle_lookup_result({:ok, _} = success, _, _), do: success + + defp handle_lookup_result({:error, :session_expired}, callsign, true) do + Agent.update(__MODULE__, fn _ -> nil end) + + with {:ok, new_key} <- login() do + do_lookup(callsign, new_key, false) + end + end + + defp handle_lookup_result({:error, :session_expired}, _, false) do + {:error, "Session expired after retry"} + end + + defp handle_lookup_result({:error, _} = error, _, _), do: error + + defp do_request(params) do + config = Application.get_env(:microwaveprop, __MODULE__, []) + + req_opts = + [url: @url, params: params] + |> maybe_add_plug(config) + |> maybe_add_retry(config) + + case Req.get(req_opts) do + {:ok, %{status: 200, body: body}} -> + {:ok, body} + + {:ok, %{status: status}} -> + {:error, "HTTP #{status}"} + + {:error, reason} -> + {:error, "Request failed: #{inspect(reason)}"} + end + end + + defp maybe_add_plug(opts, config) do + case Keyword.get(config, :plug) do + nil -> opts + plug -> Keyword.put(opts, :plug, plug) + end + end + + defp maybe_add_retry(opts, config) do + case Keyword.get(config, :retry) do + nil -> opts + retry -> Keyword.put(opts, :retry, retry) + end + end + + defp parse_session(xml) do + bytes = :binary.bin_to_list(xml) + {doc, _} = :xmerl_scan.string(bytes) + + case xpath_text(doc, ~c"//Session/Key") do + nil -> + error = xpath_text(doc, ~c"//Session/Error") || "Unknown error" + {:error, error} + + key -> + {:ok, key} + end + end + + defp parse_callsign_response(xml) do + bytes = :binary.bin_to_list(xml) + {doc, _} = :xmerl_scan.string(bytes) + + callsign_elements = :xmerl_xpath.string(~c"//Callsign/*", doc) + + if callsign_elements == [] do + case xpath_text(doc, ~c"//Session/Key") do + nil -> + {:error, :session_expired} + + _ -> + error = xpath_text(doc, ~c"//Session/Error") || "Not found" + {:error, error} + end + else + data = + callsign_elements + |> Enum.filter(&Record.is_record(&1, :xmlElement)) + |> Map.new(fn elem -> + name = + elem + |> xmlElement(:name) + |> to_string() + + value = extract_text(elem) + {name, value} + end) + + {:ok, data} + end + end + + defp xpath_text(doc, path) do + case :xmerl_xpath.string(path, doc) do + [] -> + nil + + [element | _] -> + extract_text(element) + end + end + + defp extract_text(element) do + element + |> xmlElement(:content) + |> Enum.filter(&Record.is_record(&1, :xmlText)) + |> Enum.map_join(fn text_node -> + text_node + |> xmlText(:value) + |> to_string() + end) + |> then(fn + "" -> nil + text -> text + end) + end +end diff --git a/lib/microwaveprop/qrz/record.ex b/lib/microwaveprop/qrz/record.ex new file mode 100644 index 00000000..b16cd030 --- /dev/null +++ b/lib/microwaveprop/qrz/record.ex @@ -0,0 +1,71 @@ +defmodule Microwaveprop.Qrz.Record do + @moduledoc """ + Typed struct representing the QRZ.com callsign fields this app + actually consumes. The full upstream XML has ~50 fields; the rest + are preserved in the raw `data` column on `Microwaveprop.Qrz.Callsign` + and can be read directly when needed. + """ + + @derive Jason.Encoder + + @type t :: %__MODULE__{ + call: String.t() | nil, + fname: String.t() | nil, + name: String.t() | nil, + grid: String.t() | nil, + addr1: String.t() | nil, + addr2: String.t() | nil, + state: String.t() | nil, + zip: String.t() | nil, + country: String.t() | nil, + lat: float() | nil, + lon: float() | nil + } + + defstruct [:call, :fname, :name, :grid, :addr1, :addr2, :state, :zip, :country, :lat, :lon] + + @string_fields %{ + "call" => :call, + "fname" => :fname, + "name" => :name, + "grid" => :grid, + "addr1" => :addr1, + "addr2" => :addr2, + "state" => :state, + "zip" => :zip, + "country" => :country + } + + @float_fields %{ + "lat" => :lat, + "lon" => :lon + } + + @spec from_map(map()) :: t() + def from_map(data) when is_map(data) do + %__MODULE__{} + |> parse_fields(data, @string_fields, &parse_string/1) + |> parse_fields(data, @float_fields, &parse_float/1) + end + + defp parse_fields(record, data, field_map, parser) do + Enum.reduce(field_map, record, fn {xml_key, struct_key}, acc -> + case Map.get(data, xml_key) do + nil -> acc + value -> Map.put(acc, struct_key, parser.(value)) + end + end) + end + + defp parse_string(value) when is_binary(value), do: value + defp parse_string(_), do: nil + + defp parse_float(value) when is_binary(value) do + case Float.parse(value) do + {float, _} -> float + :error -> nil + end + end + + defp parse_float(_), do: nil +end diff --git a/lib/microwaveprop/radio/callsign_client.ex b/lib/microwaveprop/radio/callsign_client.ex index dadd089e..72308bf9 100644 --- a/lib/microwaveprop/radio/callsign_client.ex +++ b/lib/microwaveprop/radio/callsign_client.ex @@ -1,9 +1,13 @@ defmodule Microwaveprop.Radio.CallsignClient do - @moduledoc "Looks up amateur radio callsign locations via gridmap.org." - - @doc """ - Fetch location for a callsign. Returns {:ok, %{callsign, gridsquare, lat, lon}} or {:error, reason}. + @moduledoc """ + Public facade for callsign → location resolution. Delegates to + `Microwaveprop.Radio.CallsignLocation`, which combines a cached + QRZ.com lookup with a Google Maps fallback geocode, and reshapes + the result so callers get back `{:ok, %{callsign, gridsquare, lat, + lon}}`. """ + alias Microwaveprop.CallsignLocation + @type location :: %{ callsign: String.t(), gridsquare: String.t() | nil, @@ -13,33 +17,18 @@ defmodule Microwaveprop.Radio.CallsignClient do @spec locate(String.t()) :: {:ok, location()} | {:error, String.t()} def locate(callsign) do - url = "https://gridmap.org/locate/#{URI.encode(callsign)}" - - case Req.get(url, req_options() ++ [receive_timeout: 10_000]) do - {:ok, %{status: 200, body: %{"latitude" => lat, "longitude" => lon} = body}} when is_number(lat) -> + case CallsignLocation.lookup(callsign) do + {:ok, result} -> {:ok, %{ - callsign: body["callsign"] || callsign, - gridsquare: body["gridsquare"], - lat: lat, - lon: lon + callsign: result.callsign, + gridsquare: result.gridsquare, + lat: result.latitude, + lon: result.longitude }} - {:ok, %{status: 200, body: _}} -> - {:error, "callsign not found"} - - {:ok, %{status: 404}} -> - {:error, "callsign not found"} - - {:ok, %{status: status}} -> - {:error, "gridmap.org HTTP #{status}"} - - {:error, reason} -> - {:error, "gridmap.org failed: #{inspect(reason)}"} + {:error, _} = error -> + error end end - - defp req_options do - Application.get_env(:microwaveprop, :callsign_req_options, []) - end end diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 2130b087..30bca655 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -1,7 +1,6 @@ defmodule MicrowavepropWeb.BeaconLive.Index do @moduledoc false use MicrowavepropWeb, :live_view - use LiveTable.LiveResource, schema: Microwaveprop.Beacons.Beacon alias Microwaveprop.Beacons @@ -57,7 +56,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do |> assign(:page_title, "Beacons") |> assign(:pending, pending) |> assign(:beacons_json, encode_beacons(beacons)) - |> assign(:data_provider, {Microwaveprop.Beacons, :approved_beacons_query, []}) + |> assign(:data_provider, {Beacons, :approved_beacons_query, []}) |> stream(:pending, pending)} end @@ -70,7 +69,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do {:noreply, socket |> stream_delete(:pending, beacon) - |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + |> push_patch(to: path_with_prefix(socket.assigns.current_path))} else {:noreply, put_flash(socket, :error, "Admins only.")} end @@ -85,7 +84,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do socket |> put_flash(:info, "Approved #{approved.callsign}.") |> stream_delete(:pending, beacon) - |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + |> push_patch(to: path_with_prefix(socket.assigns.current_path))} else {:noreply, put_flash(socket, :error, "Admins only.")} end @@ -107,7 +106,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do |> assign(:pending, pending) |> assign(:beacons_json, encode_beacons(beacons)) |> stream(:pending, pending, reset: true) - |> push_patch(to: socket.assigns.current_path |> path_with_prefix())} + |> push_patch(to: path_with_prefix(socket.assigns.current_path))} end defp admin?(%{user: %{is_admin: true}}), do: true diff --git a/mix.exs b/mix.exs index d729514e..d2959f3a 100644 --- a/mix.exs +++ b/mix.exs @@ -25,7 +25,7 @@ defmodule Microwaveprop.MixProject do def application do [ mod: {Microwaveprop.Application, []}, - extra_applications: [:logger, :runtime_tools, :os_mon] + extra_applications: [:logger, :runtime_tools, :os_mon, :xmerl] ] end diff --git a/priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs b/priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs new file mode 100644 index 00000000..3241f416 --- /dev/null +++ b/priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs @@ -0,0 +1,25 @@ +defmodule Microwaveprop.Repo.Migrations.CreateQrzCallsignsAndCallsignLocations do + use Ecto.Migration + + def change do + create table(:qrz_callsigns, primary_key: false) do + add :id, :binary_id, primary_key: true + add :callsign, :string, null: false + add :data, :map, null: false, default: %{} + timestamps(type: :utc_datetime) + end + + create unique_index(:qrz_callsigns, [:callsign]) + + create table(:callsign_locations, primary_key: false) do + add :id, :binary_id, primary_key: true + add :callsign, :string, null: false + add :latitude, :float, null: false + add :longitude, :float, null: false + add :gridsquare, :string, null: false + timestamps(type: :utc_datetime) + end + + create unique_index(:callsign_locations, [:callsign]) + end +end diff --git a/test/microwaveprop/callsign_location_test.exs b/test/microwaveprop/callsign_location_test.exs new file mode 100644 index 00000000..0cf2f08b --- /dev/null +++ b/test/microwaveprop/callsign_location_test.exs @@ -0,0 +1,184 @@ +defmodule Microwaveprop.CallsignLocationTest do + use Microwaveprop.DataCase, async: false + + alias Microwaveprop.CallsignLocation + alias Microwaveprop.CallsignLocation.Location + alias Microwaveprop.Geocoder + alias Microwaveprop.Qrz.Client + + setup do + Client.reset_session() + :ok + end + + defp stub_qrz_lookup(opts \\ []) do + lat = Keyword.get(opts, :lat) + lon = Keyword.get(opts, :lon) + + lat_xml = if lat, do: "#{lat}", else: "" + lon_xml = if lon, do: "#{lon}", else: "" + + Req.Test.expect(Client, 2, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + + if Map.has_key?(params, "username") do + Plug.Conn.send_resp(conn, 200, """ + + + session123 + + """) + else + Plug.Conn.send_resp(conn, 200, """ + + + + W1AW + Hiram Percy + Maxim + 225 Main St + Newington + CT + 06111 + United States + #{lat_xml} + #{lon_xml} + + session123 + + """) + end + end) + end + + defp stub_geocoder do + Req.Test.expect(Geocoder, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{ + "status" => "OK", + "results" => [ + %{ + "geometry" => %{ + "location" => %{"lat" => 41.714775, "lng" => -72.727260} + } + } + ] + }) + ) + end) + end + + describe "lookup/1" do + test "fetches from QRZ, geocodes, caches, and returns result" do + stub_qrz_lookup() + stub_geocoder() + + assert {:ok, result} = CallsignLocation.lookup("W1AW") + assert result.callsign == "W1AW" + assert_in_delta result.latitude, 41.714775, 0.001 + assert_in_delta result.longitude, -72.727260, 0.001 + assert is_binary(result.gridsquare) + assert String.length(result.gridsquare) == 8 + assert result.name == "Hiram Percy Maxim" + assert result.address == "225 Main St, Newington, CT, 06111, United States" + + assert Repo.get_by(Location, callsign: "W1AW") + end + + test "returns cached data without calling geocoder" do + {:ok, _} = + %Location{} + |> Location.changeset(%{ + callsign: "W1AW", + latitude: 41.714, + longitude: -72.727, + gridsquare: "FN31pr58" + }) + |> Repo.insert() + + assert {:ok, result} = CallsignLocation.lookup("W1AW") + assert result.callsign == "W1AW" + assert_in_delta result.latitude, 41.714, 0.001 + assert result.gridsquare == "FN31pr58" + end + + test "returns error when QRZ lookup fails" do + Req.Test.expect(Client, 2, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + + if Map.has_key?(params, "username") do + Plug.Conn.send_resp(conn, 200, """ + + + session123 + + """) + else + Plug.Conn.send_resp(conn, 200, """ + + + + Not found: INVALID + session123 + + + """) + end + end) + + assert {:error, _} = CallsignLocation.lookup("INVALID") + end + + test "uses QRZ lat/lon when available instead of geocoding" do + stub_qrz_lookup(lat: 33.229, lon: -96.458) + + assert {:ok, result} = CallsignLocation.lookup("W1AW") + assert result.callsign == "W1AW" + assert_in_delta result.latitude, 33.229, 0.001 + assert_in_delta result.longitude, -96.458, 0.001 + assert is_binary(result.gridsquare) + assert String.length(result.gridsquare) == 8 + assert result.name == "Hiram Percy Maxim" + assert result.address == "225 Main St, Newington, CT, 06111, United States" + end + + test "falls back to geocoding when QRZ has no lat/lon" do + stub_qrz_lookup() + stub_geocoder() + + assert {:ok, result} = CallsignLocation.lookup("W1AW") + assert_in_delta result.latitude, 41.714775, 0.001 + assert_in_delta result.longitude, -72.727260, 0.001 + end + + test "returns error when no address available" do + Req.Test.expect(Client, 2, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + + if Map.has_key?(params, "username") do + Plug.Conn.send_resp(conn, 200, """ + + + session123 + + """) + else + Plug.Conn.send_resp(conn, 200, """ + + + + W1AW + + session123 + + """) + end + end) + + assert {:error, "No address available for callsign"} = CallsignLocation.lookup("W1AW") + end + end +end diff --git a/test/microwaveprop/geocoder_test.exs b/test/microwaveprop/geocoder_test.exs new file mode 100644 index 00000000..03a3f5d7 --- /dev/null +++ b/test/microwaveprop/geocoder_test.exs @@ -0,0 +1,53 @@ +defmodule Microwaveprop.GeocoderTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Geocoder + + describe "geocode/1" do + test "returns lat/lon for a valid address" do + Req.Test.expect(Geocoder, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{ + "status" => "OK", + "results" => [%{"geometry" => %{"location" => %{"lat" => 41.714775, "lng" => -72.727260}}}] + }) + ) + end) + + assert {:ok, %{lat: 41.714775, lon: -72.727260}} = + Geocoder.geocode("225 Main St, Newington, CT 06111") + end + + test "returns error when no results found" do + Req.Test.expect(Geocoder, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp(200, Jason.encode!(%{"status" => "ZERO_RESULTS", "results" => []})) + end) + + assert {:error, "No results found"} = Geocoder.geocode("xyzzy nowhere land") + end + + test "returns error on API failure" do + Req.Test.expect(Geocoder, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.send_resp( + 200, + Jason.encode!(%{"status" => "REQUEST_DENIED", "error_message" => "Invalid API key"}) + ) + end) + + assert {:error, "Geocoding failed: REQUEST_DENIED"} = Geocoder.geocode("test") + end + + test "returns error when request fails" do + Req.Test.expect(Geocoder, &Req.Test.transport_error(&1, :timeout)) + + assert {:error, "Geocoding request failed: " <> _} = Geocoder.geocode("test") + end + end +end diff --git a/test/microwaveprop/qrz/callsign_test.exs b/test/microwaveprop/qrz/callsign_test.exs new file mode 100644 index 00000000..6697119f --- /dev/null +++ b/test/microwaveprop/qrz/callsign_test.exs @@ -0,0 +1,47 @@ +defmodule Microwaveprop.Qrz.CallsignTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Qrz.Callsign + + describe "changeset/2" do + test "valid changeset with callsign and data" do + attrs = %{callsign: "W1AW", data: %{"fname" => "Hiram", "name" => "Maxim"}} + changeset = Callsign.changeset(%Callsign{}, attrs) + + assert changeset.valid? + end + + test "requires callsign" do + changeset = Callsign.changeset(%Callsign{}, %{data: %{"fname" => "Test"}}) + assert %{callsign: ["can't be blank"]} = errors_on(changeset) + end + + test "requires data" do + changeset = Callsign.changeset(%Callsign{}, %{callsign: "W1AW"}) + assert %{data: ["can't be blank"]} = errors_on(changeset) + end + + test "uppercases callsign" do + attrs = %{callsign: "w1aw", data: %{"fname" => "Test"}} + changeset = Callsign.changeset(%Callsign{}, attrs) + + assert get_change(changeset, :callsign) == "W1AW" + end + + test "enforces unique callsign constraint" do + attrs = %{callsign: "W1AW", data: %{"fname" => "Hiram"}} + + {:ok, _} = + %Callsign{} + |> Callsign.changeset(attrs) + |> Repo.insert() + + {:error, changeset} = + %Callsign{} + |> Callsign.changeset(attrs) + |> Repo.insert() + + assert %{callsign: ["has already been taken"]} = errors_on(changeset) + end + end +end diff --git a/test/microwaveprop/qrz_test.exs b/test/microwaveprop/qrz_test.exs new file mode 100644 index 00000000..c680403e --- /dev/null +++ b/test/microwaveprop/qrz_test.exs @@ -0,0 +1,173 @@ +defmodule Microwaveprop.QrzTest do + use Microwaveprop.DataCase, async: false + + alias Microwaveprop.Qrz + alias Microwaveprop.Qrz.Callsign + alias Microwaveprop.Qrz.Client + alias Microwaveprop.Qrz.Record + + setup do + Client.reset_session() + :ok + end + + defp stub_login_and_lookup(callsign_xml) do + Req.Test.expect(Client, 2, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + + if Map.has_key?(params, "username") do + Plug.Conn.send_resp(conn, 200, """ + + + session123 + + """) + else + Plug.Conn.send_resp(conn, 200, """ + + + #{callsign_xml} + session123 + + """) + end + end) + end + + defp w1aw_xml do + """ + + W1AW + Hiram Percy + Maxim + FN31pr + + """ + end + + describe "lookup_callsign/1" do + test "fetches from API when not cached, stores in DB" do + stub_login_and_lookup(w1aw_xml()) + + assert {:ok, %Record{} = record} = Qrz.lookup_callsign("W1AW") + assert record.call == "W1AW" + assert record.fname == "Hiram Percy" + assert record.grid == "FN31pr" + + assert Repo.get_by(Callsign, callsign: "W1AW") + end + + test "returns cached data when within TTL" do + {:ok, _} = + %Callsign{} + |> Callsign.changeset(%{callsign: "W1AW", data: %{"call" => "W1AW", "fname" => "Cached"}}) + |> Repo.insert() + + # Test config forces cache_ttl_hours: 0 so entries are always stale. + # Override for this test to make the cache fresh. + original = Application.get_env(:microwaveprop, Qrz) + Application.put_env(:microwaveprop, Qrz, Keyword.put(original, :cache_ttl_hours, 24)) + + on_exit(fn -> Application.put_env(:microwaveprop, Qrz, original) end) + + assert {:ok, %Record{} = record} = Qrz.lookup_callsign("W1AW") + assert record.fname == "Cached" + end + + test "re-fetches when cache entry is stale" do + past = + DateTime.utc_now() + |> DateTime.add(-48, :hour) + |> DateTime.truncate(:second) + + Repo.insert!(%Callsign{ + callsign: "W1AW", + data: %{"call" => "W1AW", "fname" => "Old"}, + inserted_at: past, + updated_at: past + }) + + stub_login_and_lookup(w1aw_xml()) + + assert {:ok, %Record{} = record} = Qrz.lookup_callsign("W1AW") + assert record.fname == "Hiram Percy" + end + + test "upserts (updates existing record on re-fetch)" do + past = + DateTime.utc_now() + |> DateTime.add(-48, :hour) + |> DateTime.truncate(:second) + + original = + Repo.insert!(%Callsign{ + callsign: "W1AW", + data: %{"call" => "W1AW", "fname" => "Old"}, + inserted_at: past, + updated_at: past + }) + + stub_login_and_lookup(w1aw_xml()) + + assert {:ok, %Record{} = record} = Qrz.lookup_callsign("W1AW") + assert record.fname == "Hiram Percy" + + assert Repo.get!(Callsign, original.id).data["fname"] == "Hiram Percy" + end + + test "returns {:error, message} when API fails" do + Req.Test.expect(Client, 2, fn conn -> + params = Plug.Conn.fetch_query_params(conn).query_params + + if Map.has_key?(params, "username") do + Plug.Conn.send_resp(conn, 200, """ + + + session123 + + """) + else + Plug.Conn.send_resp(conn, 200, """ + + + + Not found: INVALIDCALL + session123 + + + """) + end + end) + + assert {:error, "Not found: INVALIDCALL"} = Qrz.lookup_callsign("INVALIDCALL") + end + + test "normalizes callsign to uppercase" do + stub_login_and_lookup(w1aw_xml()) + + assert {:ok, %Record{call: "W1AW"}} = Qrz.lookup_callsign("w1aw") + end + end + + describe "get_callsign/1" do + test "returns nil for unknown callsign" do + assert Qrz.get_callsign("NONEXISTENT") == nil + end + + test "returns record regardless of TTL" do + past = + DateTime.utc_now() + |> DateTime.add(-999, :hour) + |> DateTime.truncate(:second) + + Repo.insert!(%Callsign{ + callsign: "W1AW", + data: %{"call" => "W1AW"}, + inserted_at: past, + updated_at: past + }) + + assert %Callsign{callsign: "W1AW"} = Qrz.get_callsign("W1AW") + end + end +end diff --git a/test/microwaveprop_web/live/user_management_live_test.exs b/test/microwaveprop_web/live/user_management_live_test.exs index 86951dfe..83dbc54c 100644 --- a/test/microwaveprop_web/live/user_management_live_test.exs +++ b/test/microwaveprop_web/live/user_management_live_test.exs @@ -39,9 +39,7 @@ defmodule MicrowavepropWeb.UserManagementLiveTest do {:ok, view, _} = live(conn, ~p"/users") - view - |> render_click("delete", %{"id" => target.id}) - + render_click(view, "delete", %{"id" => target.id}) refute Accounts.get_user_by_email(target.email) end end