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:
parent
f733ddb6ac
commit
99e7560601
20 changed files with 1083 additions and 41 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
lib/microwaveprop/callsign_location.ex
Normal file
125
lib/microwaveprop/callsign_location.ex
Normal 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
|
||||
27
lib/microwaveprop/callsign_location/location.ex
Normal file
27
lib/microwaveprop/callsign_location/location.ex
Normal 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
|
||||
40
lib/microwaveprop/geocoder.ex
Normal file
40
lib/microwaveprop/geocoder.ex
Normal 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
72
lib/microwaveprop/qrz.ex
Normal 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
|
||||
24
lib/microwaveprop/qrz/callsign.ex
Normal file
24
lib/microwaveprop/qrz/callsign.ex
Normal 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
|
||||
189
lib/microwaveprop/qrz/client.ex
Normal file
189
lib/microwaveprop/qrz/client.ex
Normal 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
|
||||
71
lib/microwaveprop/qrz/record.ex
Normal file
71
lib/microwaveprop/qrz/record.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
mix.exs
2
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
184
test/microwaveprop/callsign_location_test.exs
Normal file
184
test/microwaveprop/callsign_location_test.exs
Normal 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
|
||||
53
test/microwaveprop/geocoder_test.exs
Normal file
53
test/microwaveprop/geocoder_test.exs
Normal 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
|
||||
47
test/microwaveprop/qrz/callsign_test.exs
Normal file
47
test/microwaveprop/qrz/callsign_test.exs
Normal 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
|
||||
173
test/microwaveprop/qrz_test.exs
Normal file
173
test/microwaveprop/qrz_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue