prop/test/microwaveprop/qrz/client_test.exs
Graham McIntire 8db31794a1
test: cover qrz/client
13 characterization tests: login success/failure, session caching,
automatic re-login on session expiry with retry, lookup errors
(not found, HTTP 5xx, malformed XML), reset_session/0.
2026-04-16 14:10:50 -05:00

319 lines
9.7 KiB
Elixir

defmodule Microwaveprop.Qrz.ClientTest do
# The Qrz.Client Agent is started once in the app supervision tree and
# cached session state is process-global, so tests cannot run concurrently.
use ExUnit.Case, async: false
alias Microwaveprop.Qrz.Client
@session_xml """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Key>2331uf894c4bd29f3923f3bacf02c532d7bd9</Key>
<Count>61</Count>
<SubExp>Wed Jan 1 12:34:03 2025</SubExp>
<GMTime>Sun Nov 16 04:48:02 2025</GMTime>
</Session>
</QRZDatabase>
"""
@callsign_xml """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Callsign>
<call>W1AW</call>
<fname>Hiram Percy</fname>
<name>Maxim</name>
<addr2>Newington</addr2>
<state>CT</state>
<country>United States</country>
<grid>FN31pr</grid>
<lat>41.714775</lat>
<lon>-72.727260</lon>
</Callsign>
<Session>
<Key>2331uf894c4bd29f3923f3bacf02c532d7bd9</Key>
<Count>62</Count>
<GMTime>Sun Nov 16 04:48:02 2025</GMTime>
</Session>
</QRZDatabase>
"""
@session_expired_xml """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Error>Session Timeout</Error>
<GMTime>Sun Nov 16 04:48:02 2025</GMTime>
</Session>
</QRZDatabase>
"""
@bad_credentials_xml """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Error>Username/password incorrect</Error>
<GMTime>Sun Nov 16 04:48:02 2025</GMTime>
</Session>
</QRZDatabase>
"""
@not_found_xml """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session>
<Key>2331uf894c4bd29f3923f3bacf02c532d7bd9</Key>
<Error>Not found: NOSUCHCALL</Error>
<GMTime>Sun Nov 16 04:48:02 2025</GMTime>
</Session>
</QRZDatabase>
"""
setup do
Client.reset_session()
:ok
end
describe "login/0" do
test "stores the session key returned by QRZ" do
config = Application.get_env(:microwaveprop, Client, [])
expected_username = Keyword.fetch!(config, :username)
expected_password = Keyword.fetch!(config, :password)
expected_agent = Keyword.get(config, :agent, "microwaveprop/1.0")
Req.Test.stub(Client, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
assert params["username"] == expected_username
assert params["password"] == expected_password
assert params["agent"] == expected_agent
Plug.Conn.send_resp(conn, 200, @session_xml)
end)
assert {:ok, "2331uf894c4bd29f3923f3bacf02c532d7bd9"} = Client.login()
end
test "returns {:error, message} when credentials are rejected" do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, @bad_credentials_xml)
end)
assert {:error, "Username/password incorrect"} = Client.login()
end
test "returns {:error, _} on non-200 HTTP responses" do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 500, "boom")
end)
assert {:error, "HTTP 500"} = Client.login()
end
test "returns {:error, _} on transport failures" do
Req.Test.stub(Client, fn conn ->
Req.Test.transport_error(conn, :econnrefused)
end)
assert {:error, "Request failed: " <> _} = Client.login()
end
end
describe "lookup/1 with no cached session" do
test "logs in transparently and returns a parsed callsign map" do
Req.Test.expect(Client, 2, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
cond do
Map.has_key?(params, "username") ->
Plug.Conn.send_resp(conn, 200, @session_xml)
Map.has_key?(params, "callsign") ->
assert params["callsign"] == "W1AW"
assert params["s"] == "2331uf894c4bd29f3923f3bacf02c532d7bd9"
Plug.Conn.send_resp(conn, 200, @callsign_xml)
end
end)
assert {:ok, record} = Client.lookup("W1AW")
assert record["call"] == "W1AW"
assert record["fname"] == "Hiram Percy"
assert record["name"] == "Maxim"
assert record["grid"] == "FN31pr"
assert record["lat"] == "41.714775"
assert record["lon"] == "-72.727260"
end
end
describe "lookup/1 with a cached session" do
test "skips login and uses the cached key" do
# First call seeds the session cache.
Req.Test.expect(Client, 2, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
cond do
Map.has_key?(params, "username") ->
Plug.Conn.send_resp(conn, 200, @session_xml)
Map.has_key?(params, "callsign") ->
Plug.Conn.send_resp(conn, 200, @callsign_xml)
end
end)
assert {:ok, _} = Client.lookup("W1AW")
# Now only the lookup request should hit the stub — no login.
Req.Test.expect(Client, 1, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
refute Map.has_key?(params, "username")
assert params["s"] == "2331uf894c4bd29f3923f3bacf02c532d7bd9"
Plug.Conn.send_resp(conn, 200, @callsign_xml)
end)
assert {:ok, record} = Client.lookup("W1AW")
assert record["call"] == "W1AW"
end
end
describe "lookup/1 session expiry" do
test "re-logs-in and retries when QRZ returns session_expired" do
# Prime the cache with a known stale key.
Req.Test.expect(Client, 1, fn conn ->
Plug.Conn.send_resp(conn, 200, @session_xml)
end)
assert {:ok, _} = Client.login()
# Sequence of responses for the retry flow:
# 1. lookup with stale key → session_expired XML
# 2. re-login → fresh session XML
# 3. lookup with new key → success
{:ok, agent} = Agent.start_link(fn -> 0 end)
Req.Test.stub(Client, fn conn ->
n = Agent.get_and_update(agent, fn n -> {n + 1, n + 1} end)
params = Plug.Conn.fetch_query_params(conn).query_params
case n do
1 ->
assert Map.has_key?(params, "callsign")
Plug.Conn.send_resp(conn, 200, @session_expired_xml)
2 ->
assert Map.has_key?(params, "username")
Plug.Conn.send_resp(conn, 200, @session_xml)
3 ->
assert Map.has_key?(params, "callsign")
Plug.Conn.send_resp(conn, 200, @callsign_xml)
end
end)
assert {:ok, record} = Client.lookup("W1AW")
assert record["call"] == "W1AW"
assert Agent.get(agent, & &1) == 3
end
test "returns an error when a retry login also expires" do
# Prime with a cached key.
Req.Test.expect(Client, 1, fn conn ->
Plug.Conn.send_resp(conn, 200, @session_xml)
end)
assert {:ok, _} = Client.login()
# Every response lacks a Session/Key, so the retried lookup still sees
# :session_expired and the client bails out.
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, @session_expired_xml)
end)
assert {:error, _} = Client.lookup("W1AW")
end
end
describe "lookup/1 error responses" do
test "returns a not-found error when QRZ reports an unknown callsign" do
Req.Test.expect(Client, 2, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
cond do
Map.has_key?(params, "username") ->
Plug.Conn.send_resp(conn, 200, @session_xml)
Map.has_key?(params, "callsign") ->
Plug.Conn.send_resp(conn, 200, @not_found_xml)
end
end)
assert {:error, "Not found: NOSUCHCALL"} = Client.lookup("NOSUCHCALL")
end
test "bubbles up HTTP errors from the lookup request" do
# Seed the cache so the lookup is the only request we need to stub.
Req.Test.expect(Client, 1, fn conn ->
Plug.Conn.send_resp(conn, 200, @session_xml)
end)
assert {:ok, _} = Client.login()
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 503, "service unavailable")
end)
assert {:error, "HTTP 503"} = Client.lookup("W1AW")
end
end
describe "reset_session/0" do
test "clears a cached session so the next lookup re-logs-in" do
Req.Test.expect(Client, 1, fn conn ->
Plug.Conn.send_resp(conn, 200, @session_xml)
end)
assert {:ok, _} = Client.login()
assert :ok = Client.reset_session()
# Because the cache was cleared, lookup must perform a fresh login
# before the callsign request. Expect exactly two requests.
Req.Test.expect(Client, 2, fn conn ->
params = Plug.Conn.fetch_query_params(conn).query_params
cond do
Map.has_key?(params, "username") ->
Plug.Conn.send_resp(conn, 200, @session_xml)
Map.has_key?(params, "callsign") ->
Plug.Conn.send_resp(conn, 200, @callsign_xml)
end
end)
assert {:ok, %{"call" => "W1AW"}} = Client.lookup("W1AW")
end
end
describe "XML parsing edge cases" do
test "login on malformed XML crashes (xmerl is strict)" do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, "not xml at all")
end)
# xmerl_scan.string/1 exits on unparseable input. The current client
# does not guard this, so the caller sees an exit rather than an
# {:error, _} tuple.
assert catch_exit(Client.login())
end
test "login returns {:error, _} on empty Session element" do
Req.Test.stub(Client, fn conn ->
Plug.Conn.send_resp(conn, 200, """
<?xml version="1.0" encoding="utf-8"?>
<QRZDatabase version="1.34">
<Session/>
</QRZDatabase>
""")
end)
assert {:error, "Unknown error"} = Client.login()
end
end
end