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