From 8db31794a14ed80dd2ea3a00e64ea88a13ac5648 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 14:10:50 -0500 Subject: [PATCH] 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. --- test/microwaveprop/qrz/client_test.exs | 319 +++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 test/microwaveprop/qrz/client_test.exs 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