diff --git a/test/microwaveprop/qrz/client_test.exs b/test/microwaveprop/qrz/client_test.exs
new file mode 100644
index 00000000..4a7c9744
--- /dev/null
+++ b/test/microwaveprop/qrz/client_test.exs
@@ -0,0 +1,319 @@
+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 """
+
+
+
+ 2331uf894c4bd29f3923f3bacf02c532d7bd9
+ 61
+ Wed Jan 1 12:34:03 2025
+ Sun Nov 16 04:48:02 2025
+
+
+ """
+
+ @callsign_xml """
+
+
+
+ W1AW
+ Hiram Percy
+ Maxim
+ Newington
+ CT
+ United States
+ FN31pr
+ 41.714775
+ -72.727260
+
+
+ 2331uf894c4bd29f3923f3bacf02c532d7bd9
+ 62
+ Sun Nov 16 04:48:02 2025
+
+
+ """
+
+ @session_expired_xml """
+
+
+
+ Session Timeout
+ Sun Nov 16 04:48:02 2025
+
+
+ """
+
+ @bad_credentials_xml """
+
+
+
+ Username/password incorrect
+ Sun Nov 16 04:48:02 2025
+
+
+ """
+
+ @not_found_xml """
+
+
+
+ 2331uf894c4bd29f3923f3bacf02c532d7bd9
+ Not found: NOSUCHCALL
+ Sun Nov 16 04:48:02 2025
+
+
+ """
+
+ 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, """
+
+
+
+
+ """)
+ end)
+
+ assert {:error, "Unknown error"} = Client.login()
+ end
+ end
+end