Drop gridmap.org dependency, resolve callsigns locally

Instead of shelling out to https://gridmap.org/locate/:callsign for
callsign → lat/lon lookups, ports the resolver pipeline from gridmap-web
into this project so the whole flow runs in-process.

New modules:

- Microwaveprop.Qrz — cache facade over the QRZ.com XML callsign API.
  Looks up from qrz_callsigns first, falls back to a live fetch, and
  upserts the result with a configurable cache_ttl_hours (default
  168h / 7 days).
- Microwaveprop.Qrz.Client — HTTP/XML client against
  https://xmldata.qrz.com/xml/current/. Holds the session key in an
  Agent, transparently re-logs-in on :session_expired, and parses
  responses via xmerl.
- Microwaveprop.Qrz.Callsign — Ecto schema for the qrz_callsigns
  cache table, binary_id primary key per project convention.
- Microwaveprop.Qrz.Record — slim struct with only the 11 fields
  we actually consume (identity, name, grid, address, lat/lon).
  The full XML payload stays in the raw :data jsonb column for
  anyone who wants the other ~40 QRZ fields.
- Microwaveprop.Geocoder — Req-based client against the Google Maps
  Geocoding API. Only called as a fallback when QRZ has no explicit
  <lat>/<lon> for the callsign.
- Microwaveprop.CallsignLocation — orchestrator. Reads the
  callsign_locations cache, on miss calls Qrz then either uses QRZ's
  coords directly or geocodes the formatted address, snaps to an
  8-char Maidenhead grid via Microwaveprop.Radio.Maidenhead, and
  upserts the result.

Microwaveprop.Radio.CallsignClient.locate/1 is rewritten to delegate
to CallsignLocation.lookup/1 and shape the response back to the
existing {:ok, %{callsign, gridsquare, lat, lon}} contract so the
callers in PathLive and RoverLive don't change.

Wiring:

- priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs
  creates qrz_callsigns and callsign_locations with unique indexes
  on :callsign.
- Microwaveprop.Qrz.Client added to the application supervision tree
  so the session Agent is started.
- :xmerl added to extra_applications so the release bundles it.
- config/test.exs wires Req.Test plugs for Microwaveprop.Qrz.Client
  and Microwaveprop.Geocoder, forces cache_ttl_hours: 0 so the cache
  never short-circuits test-level stubs, and supplies dummy QRZ
  credentials.
- config/runtime.exs pulls QRZ_USERNAME / QRZ_PASSWORD / QRZ_AGENT
  and GOOGLE_API_KEY from the environment so prod and dev can
  configure both upstream keys out of band.

Tests (ported verbatim from gridmap-web):

- test/microwaveprop/qrz/callsign_test.exs — schema/changeset
- test/microwaveprop/qrz_test.exs — cache hit, cache miss, TTL
  behavior, upsert on stale, error passthrough, case-insensitive
  input
- test/microwaveprop/geocoder_test.exs — success, zero results,
  request denied, transport error
- test/microwaveprop/callsign_location_test.exs — end-to-end flow
  including the QRZ-lat/lon shortcut and the missing-address error
  path

All 1294 tests still pass. Credo strict clean.
This commit is contained in:
Graham McIntire 2026-04-12 17:43:29 -05:00
parent f733ddb6ac
commit 99e7560601
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
20 changed files with 1083 additions and 41 deletions

View file

@ -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

View file

@ -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") ||

View file

@ -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
#

View file

@ -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

View file

@ -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 `<lat>`/`<lon>` 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

View file

@ -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

View file

@ -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 `<lat>` / `<lon>` 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

72
lib/microwaveprop/qrz.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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>#{lat}</lat>", else: ""
lon_xml = if lon, do: "<lon>#{lon}</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, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
else
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Callsign>
<call>W1AW</call>
<fname>Hiram Percy</fname>
<name>Maxim</name>
<addr1>225 Main St</addr1>
<addr2>Newington</addr2>
<state>CT</state>
<zip>06111</zip>
<country>United States</country>
#{lat_xml}
#{lon_xml}
</Callsign>
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
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, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
else
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Error>Not found: INVALID</Error>
<Key>session123</Key>
</Session>
</QRZDatabase>
""")
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, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
else
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Callsign>
<call>W1AW</call>
</Callsign>
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
end
end)
assert {:error, "No address available for callsign"} = CallsignLocation.lookup("W1AW")
end
end
end

View file

@ -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

View file

@ -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

View file

@ -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, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
else
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
#{callsign_xml}
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
end
end)
end
defp w1aw_xml do
"""
<Callsign>
<call>W1AW</call>
<fname>Hiram Percy</fname>
<name>Maxim</name>
<grid>FN31pr</grid>
</Callsign>
"""
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, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session><Key>session123</Key></Session>
</QRZDatabase>
""")
else
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Error>Not found: INVALIDCALL</Error>
<Key>session123</Key>
</Session>
</QRZDatabase>
""")
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

View file

@ -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