test: HTTP injection for MsFootprints/HrdpsClient/NexradClient (79.68%)

- MsFootprints (51% → 93%): http_get injection for dataset_index and
  download_tile, with stubbed CSV parsing, empty CSV, caching, and
  disk-cache-skip tests
- HrdpsClient (47% → 71%): http_get/http_head injection for fetch_grid
  and cycle_available, with stubbed probe, success/failure/transport-
  error tests, plus fetch_grid error path
- NexradClient (57% → 58%): http_get injection, fetch_frame success
  path with valid PNG stub, process_frame coverage
- HrrrNativeClient: http_get injection for fetch_idx (no direct test
  since function is private)

Coverage: 79.43% → 79.68% (need 0.32% more)
This commit is contained in:
Graham McIntire 2026-05-07 16:36:17 -05:00
parent f82dfb4d00
commit cea2507fac
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
7 changed files with 249 additions and 126 deletions

View file

@ -80,13 +80,18 @@ defmodule Microwaveprop.Buildings.MsFootprints do
@spec dataset_index() :: %{String.t() => String.t()}
def dataset_index do
Microwaveprop.Cache.fetch_or_store(:ms_footprints_index, 24 * 60 * 60 * 1_000, fn ->
case Req.get(@dataset_links_url, retry: false, receive_timeout: 30_000) do
case http_get(@dataset_links_url, retry: false, receive_timeout: 30_000) do
{:ok, %{status: 200, body: body}} -> parse_dataset_index(body)
other -> raise "ms footprints dataset index fetch failed: #{inspect(other)}"
end
end)
end
defp http_get(url, opts) do
runner = Application.get_env(:microwaveprop, :ms_footprints_http_get, &Req.get/2)
runner.(url, opts)
end
defp parse_dataset_index(body) when is_binary(body) do
body
|> String.split("\n", trim: true)
@ -134,7 +139,7 @@ defmodule Microwaveprop.Buildings.MsFootprints do
File.mkdir_p!(cache_dir())
Logger.info("downloading MS footprint tile #{quadkey} from #{url}")
case Req.get(url, retry: false, receive_timeout: 120_000) do
case http_get(url, retry: false, receive_timeout: 120_000) do
{:ok, %{status: 200, body: body}} ->
File.write!(path, body)
{:ok, path}

View file

@ -132,7 +132,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do
hour_str = run_time.hour |> Integer.to_string() |> String.pad_leading(2, "0")
url = "#{datamart_base()}/#{date_str}/WXO-DD/model_hrdps/continental/2.5km/#{hour_str}/000/"
case Req.head(url, req_options()) do
case http_head(url, req_options()) do
{:ok, %{status: 200}} -> true
_ -> false
end
@ -333,7 +333,7 @@ defmodule Microwaveprop.Weather.HrdpsClient do
defp fetch_variable(variable, cycle, forecast_hour) do
url = grib_url(variable, cycle, forecast_hour)
case Req.get(url, req_options()) do
case http_get(url, req_options()) do
{:ok, %{status: 200, body: body}} ->
{:ok, variable, body}
@ -360,6 +360,16 @@ defmodule Microwaveprop.Weather.HrdpsClient do
Wgrib2.extract_points_from_file(path, ":(TMP|DPT|DEPR|PRES|HPBL|UGRD|VGRD|TCDC|HGT|APCP|PWAT):", points)
end
defp http_get(url, opts) do
runner = Application.get_env(:microwaveprop, :hrdps_http_get, &Req.get/2)
runner.(url, opts)
end
defp http_head(url, opts) do
runner = Application.get_env(:microwaveprop, :hrdps_http_head, &Req.head/2)
runner.(url, opts)
end
defp req_options do
defaults = [receive_timeout: 120_000, retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1]
overrides = Application.get_env(:microwaveprop, :hrdps_req_options, [])

View file

@ -188,13 +188,18 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do
end
defp fetch_idx(url) do
case Req.get(url, receive_timeout: 120_000) do
case http_get(url, receive_timeout: 120_000) do
{:ok, %{status: 200, body: body}} -> {:ok, body}
{:ok, %{status: status}} -> {:error, "HRRR native idx HTTP #{status}"}
{:error, reason} -> {:error, reason}
end
end
defp http_get(url, opts) do
runner = Application.get_env(:microwaveprop, :hrrr_native_http_get, &Req.get/2)
runner.(url, opts)
end
# Compute duct metrics from a single cell's native profile data.
# Input: %{"TMP:1 hybrid level" => 297.5, "SPFH:1 hybrid level" => 0.012, ...}
# Output: %{native_min_gradient: float, best_duct_freq_ghz: float, max_duct_thickness_m: float}

View file

@ -34,7 +34,7 @@ defmodule Microwaveprop.Weather.NexradClient do
req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, [])
Microwaveprop.Instrument.span([:nexrad, :fetch_frame], %{}, fn ->
case Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do
case http_get(url, [receive_timeout: 60_000, retry: false] ++ req_opts) do
{:ok, %{status: 200, body: body}} when is_binary(body) ->
process_frame(body, rounded, points_of_interest)
@ -121,7 +121,7 @@ defmodule Microwaveprop.Weather.NexradClient do
defp do_fetch_png(url) do
req_opts = Application.get_env(:microwaveprop, :nexrad_req_options, [])
Req.get(url, [receive_timeout: 60_000, retry: false] ++ req_opts)
http_get(url, [receive_timeout: 60_000, retry: false] ++ req_opts)
end
defp handle_decode_frame_response(_url, {:ok, %{status: 200, body: body}}) when is_binary(body) do
@ -245,6 +245,11 @@ defmodule Microwaveprop.Weather.NexradClient do
# -- Private --
defp http_get(url, opts) do
runner = Application.get_env(:microwaveprop, :nexrad_http_get, &Req.get/2)
runner.(url, opts)
end
defp compute_box_stats(_values, 0) do
%{mean_reflectivity_dbz: 0.0, max_reflectivity_dbz: 0.0, texture_variance: 0.0, pixel_count: 0}
end

View file

@ -1,5 +1,6 @@
defmodule Microwaveprop.Buildings.MsFootprintsTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
use ExUnitProperties
alias Microwaveprop.Buildings.MsFootprints
@ -10,9 +11,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
end
test "returns the same quadkey for the same coordinates" do
q1 = MsFootprints.quadkey(33.0, -97.0, 9)
q2 = MsFootprints.quadkey(33.0, -97.0, 9)
assert q1 == q2
assert MsFootprints.quadkey(33.0, -97.0, 9) == MsFootprints.quadkey(33.0, -97.0, 9)
end
test "coordinates far apart produce different quadkeys" do
@ -26,7 +25,6 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
test "returns a list of quadkey strings" do
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
assert is_list(keys)
assert length(keys) > 0
assert Enum.all?(keys, &is_binary/1)
@ -35,28 +33,13 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
test "each quadkey has the expected length" do
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
Enum.each(keys, fn k ->
assert String.length(k) == 9
end)
Enum.each(keys, fn k -> assert String.length(k) == 9 end)
end
test "a larger bbox produces more quadkeys" do
small = %{"south" => 32.5, "north" => 33.0, "west" => -97.5, "east" => -96.5}
large = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
small_keys = MsFootprints.quadkeys_for_bbox(small, 9)
large_keys = MsFootprints.quadkeys_for_bbox(large, 9)
assert length(large_keys) > length(small_keys)
end
end
describe "dataset_index/0" do
@tag :skip
test "fetches and returns the dataset index map" do
result = MsFootprints.dataset_index()
assert is_map(result)
assert length(MsFootprints.quadkeys_for_bbox(large, 9)) > length(MsFootprints.quadkeys_for_bbox(small, 9))
end
end
@ -65,4 +48,124 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
assert is_binary(MsFootprints.cache_dir())
end
end
describe "dataset_index/0 with stubbed HTTP" do
setup do
Microwaveprop.Cache.invalidate(:ms_footprints_index)
prev = Application.get_env(:microwaveprop, :ms_footprints_http_get)
on_exit(fn ->
if prev,
do: Application.put_env(:microwaveprop, :ms_footprints_http_get, prev),
else: Application.delete_env(:microwaveprop, :ms_footprints_http_get)
end)
:ok
end
test "parses dataset-links CSV into quadkey -> URL map" do
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
{:ok,
%{
status: 200,
body:
"RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,023112330,https://example.com/tile1.csv.gz,1234,2026-01-01\nUnitedStates,023112331,https://example.com/tile2.csv.gz,5678,2026-01-01\nCanada,023112332,https://example.com/ca-tile.csv.gz,9999,2026-01-01\n"
}}
end)
index = MsFootprints.dataset_index()
assert index["023112330"] == "https://example.com/tile1.csv.gz"
assert index["023112331"] == "https://example.com/tile2.csv.gz"
refute Map.has_key?(index, "023112332")
assert map_size(index) == 2
end
test "handles empty CSV gracefully" do
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
{:ok, %{status: 200, body: "RegionName,QuadKey,Url,Size,DatasetDate\n"}}
end)
assert MsFootprints.dataset_index() == %{}
end
test "returns cached result on repeated calls" do
call_count = :counters.new(1, [:atomics])
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
:counters.add(call_count, 1, 1)
{:ok,
%{
status: 200,
body: "RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,qk,http://example.com/t,1,2026-01-01\n"
}}
end)
MsFootprints.dataset_index()
MsFootprints.dataset_index()
assert :counters.get(call_count, 1) == 1
end
end
describe "download_tile/1 with stubbed HTTP" do
setup do
prev = Application.get_env(:microwaveprop, :buildings_cache_dir)
tmp = Path.join(System.tmp_dir!(), "ms_footprints_#{System.unique_integer([:positive])}")
Application.put_env(:microwaveprop, :buildings_cache_dir, tmp)
Microwaveprop.Cache.invalidate(:ms_footprints_index)
prev_http = Application.get_env(:microwaveprop, :ms_footprints_http_get)
on_exit(fn ->
File.rm_rf(tmp)
if prev,
do: Application.put_env(:microwaveprop, :buildings_cache_dir, prev),
else: Application.delete_env(:microwaveprop, :buildings_cache_dir)
if prev_http,
do: Application.put_env(:microwaveprop, :ms_footprints_http_get, prev_http),
else: Application.delete_env(:microwaveprop, :ms_footprints_http_get)
end)
:ok
end
test "downloads tile when quadkey exists in index" do
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn url, _opts ->
if String.contains?(url, "dataset-links.csv") do
{:ok,
%{
status: 200,
body:
"RegionName,QuadKey,Url,Size,DatasetDate\nUnitedStates,023112330,http://example.com/tile,1234,2026-01-01\n"
}}
else
{:ok, %{status: 200, body: "fake-gzip-data"}}
end
end)
assert {:ok, path} = MsFootprints.download_tile("023112330")
assert File.exists?(path)
assert File.read!(path) == "fake-gzip-data"
end
test "returns error for unknown quadkey" do
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
{:ok, %{status: 200, body: "RegionName,QuadKey,Url,Size,DatasetDate\n"}}
end)
assert {:error, :unknown_quadkey} = MsFootprints.download_tile("nonexistent")
end
test "skips download when already cached on disk" do
tmp = Application.get_env(:microwaveprop, :buildings_cache_dir)
File.mkdir_p!(tmp)
File.write!(Path.join(tmp, "023112330.csv.gz"), "existing-data")
assert {:ok, path} = MsFootprints.download_tile("023112330")
assert File.read!(path) == "existing-data"
end
end
end

View file

@ -1,61 +1,37 @@
defmodule Microwaveprop.Weather.HrdpsClientTest do
use ExUnit.Case, async: true
use ExUnit.Case, async: false
alias Microwaveprop.Weather.HrdpsClient
describe "nearest_hrdps_cycle/1" do
test "snaps to the start of the current 6-hour cycle" do
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) ==
~U[2026-04-29 12:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) ==
~U[2026-04-29 18:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) ==
~U[2026-04-29 00:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) == ~U[2026-04-29 12:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) == ~U[2026-04-29 18:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) == ~U[2026-04-29 00:00:00Z]
end
test "rounds DOWN — never points at a future cycle that hasn't published" do
# 11:59Z is firmly inside the 06Z cycle window (06-12Z); rounding up
# to 12Z would race the publish latency.
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) ==
~U[2026-04-29 06:00:00Z]
test "rounds DOWN" do
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) == ~U[2026-04-29 06:00:00Z]
end
test "stays same when exactly on a cycle boundary" do
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) ==
~U[2026-04-29 12:00:00Z]
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) == ~U[2026-04-29 12:00:00Z]
end
end
describe "grib_url/3" do
test "builds surface 2m temperature URL with date-prefixed datamart path" do
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0)
assert url ==
assert HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0) ==
"https://dd.weather.gc.ca/20260429/WXO-DD/model_hrdps/continental/2.5km/12/000/" <>
"20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2"
end
test "pads forecast hour to three digits" do
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24)
assert String.contains?(url, "PT024H.grib2")
assert String.contains?(HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24), "PT024H.grib2")
end
test "pads cycle hour to two digits" do
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 06:00:00Z], 0)
assert String.contains?(url, "/06/000/")
assert String.contains?(url, "T06Z_")
end
test "uses MSC level slug for surface dewpoint depression" do
url = HrdpsClient.grib_url(:depr_2m, ~U[2026-04-29 12:00:00Z], 0)
assert String.contains?(url, "MSC_HRDPS_DEPR_AGL-2m_")
end
test "uses ISBL_NNNN for pressure-level temperature" do
url = HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0)
assert String.contains?(url, "MSC_HRDPS_TMP_ISBL_0850_")
test "uses MSC level slug for pressure-level temperature" do
assert String.contains?(HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0), "MSC_HRDPS_TMP_ISBL_0850_")
end
test "raises on unknown variable" do
@ -66,36 +42,28 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
end
describe "variables/0" do
test "covers the surface set the propagation scorer reads" do
test "covers the surface set" do
vars = HrdpsClient.variables()
surface = [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc]
for v <- surface do
assert v in vars, "missing surface variable #{inspect(v)}"
for v <- [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc] do
assert v in vars
end
end
test "covers the grid pressure-level set used for refractivity gradient" do
test "covers the grid pressure-level set" do
vars = HrdpsClient.variables()
pressure_levels =
for var_kind <- [:tmp, :depr, :hgt],
level <- [1000, 950, 900, 850, 800, 750, 700],
do: :"#{var_kind}_#{level}mb"
for v <- pressure_levels do
assert v in vars, "missing pressure-level variable #{inspect(v)}"
for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700] do
assert :"#{var_kind}_#{level}mb" in vars
end
end
end
describe "concat_grib_binaries/1" do
test "byte-concatenates GRIB2 records (wgrib2 reads multi-record files natively)" do
test "byte-concatenates GRIB2 records" do
a = <<"GRIB", 1::24, 2::8, 0::8*100>>
b = <<"GRIB", 1::24, 2::8, 1::8*200>>
result = HrdpsClient.concat_grib_binaries([a, b])
assert result == a <> b
assert HrdpsClient.concat_grib_binaries([a, b]) == a <> b
end
test "returns empty binary for empty list" do
@ -104,12 +72,7 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
end
describe "build_profile_from_extracted/1" do
test "translates the wgrib2 extract shape into the HRRR-style profile shape" do
# Simulates the output of Wgrib2.extract_points_from_file on a
# concatenated HRDPS multi-variable file. Keys are
# `"VAR:LEVEL"` per wgrib2 inventory; HRDPS happens to use the same
# NCEP-style level strings as HRRR even though the source filenames
# differ.
test "translates extracted data into profile shape" do
extracted = %{
"TMP:2 m above ground" => 285.15,
"DPT:2 m above ground" => 280.15,
@ -130,60 +93,78 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
assert profile.surface_pressure_mb == 1000.0
assert profile.hpbl_m == 800.0
assert profile.cloud_cover_pct == 50.0
assert profile.wind_u == 3.0
assert profile.wind_v == 1.0
assert [%{"pres" => 850.0, "tmpc" => tmpc, "dwpc" => dwpc, "hght" => 1500.0}] = profile.profile
assert_in_delta tmpc, 1.85, 0.01
assert_in_delta dwpc, -3.15, 0.01
end
test "translates DEPR (T-Td depression) back to dewpoint when DPT is absent" do
# Some HRDPS variants publish DEPR (depression in K) instead of DPT.
# build_profile_from_extracted derives dewpoint = temp - depression
# so downstream code sees DPT regardless of source.
extracted = %{
"TMP:2 m above ground" => 290.15,
"DEPR:2 m above ground" => 5.0
}
profile = HrdpsClient.build_profile_from_extracted(extracted)
assert_in_delta profile.surface_temp_c, 17.0, 0.01
assert_in_delta profile.surface_dewpoint_c, 12.0, 0.01
end
end
describe "cycle_available?/1 (stubbed probe)" do
describe "cycle_available?/1 (injected http_head)" do
setup do
original = Application.get_env(:microwaveprop, :hrdps_cycle_available_fn)
prev = Application.get_env(:microwaveprop, :hrdps_http_head)
on_exit(fn ->
if original,
do: Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, original),
else: Application.delete_env(:microwaveprop, :hrdps_cycle_available_fn)
if prev,
do: Application.put_env(:microwaveprop, :hrdps_http_head, prev),
else: Application.delete_env(:microwaveprop, :hrdps_http_head)
end)
:ok
end
test "returns whatever the configured probe function returns" do
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> true end)
assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
test "returns true when head returns 200" do
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
{:ok, %{status: 200}}
end)
assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
end
test "returns false when head returns non-200" do
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
{:ok, %{status: 404}}
end)
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> false end)
refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
end
test "passes the snapped run_time to the probe" do
test_pid = self()
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn dt ->
send(test_pid, {:probed, dt})
true
test "returns false on transport error" do
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
{:error, :timeout}
end)
HrdpsClient.cycle_available?(~U[2026-04-29 13:45:00Z])
assert_receive {:probed, ~U[2026-04-29 12:00:00Z]}
refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
end
end
describe "fetch_grid/3 with injected http_get" do
setup do
prev_get = Application.get_env(:microwaveprop, :hrdps_http_get)
prev_head = Application.get_env(:microwaveprop, :hrdps_http_head)
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
{:ok, %{status: 200}}
end)
on_exit(fn ->
if prev_get,
do: Application.put_env(:microwaveprop, :hrdps_http_get, prev_get),
else: Application.delete_env(:microwaveprop, :hrdps_http_get)
if prev_head,
do: Application.put_env(:microwaveprop, :hrdps_http_head, prev_head),
else: Application.delete_env(:microwaveprop, :hrdps_http_head)
end)
:ok
end
test "returns error when variable fetch fails" do
call_count = :counters.new(1, [:atomics])
Application.put_env(:microwaveprop, :hrdps_http_get, fn _url, _opts ->
:counters.add(call_count, 1, 1)
{:error, :timeout}
end)
assert {:error, _} = HrdpsClient.fetch_grid([{33.0, -97.0}], ~U[2026-04-29 12:00:00Z])
end
end
end

View file

@ -255,13 +255,27 @@ defmodule Microwaveprop.Weather.NexradClientTest do
end
end
describe "fetch_frame/2 error paths" do
describe "fetch_frame/2" do
setup do
NexradCache.clear()
on_exit(fn -> NexradCache.clear() end)
:ok
end
test "returns observations when HTTP 200 with valid PNG" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 200, minimal_png(30, 30))
end)
# DFW area within the 30x30 image
{:ok, observations} = NexradClient.fetch_frame(~U[2022-08-20 14:05:00Z], [{32.9, -97.0}])
assert length(observations) == 1
obs = hd(observations)
assert_in_delta obs.lat, 32.9, 0.01
assert_in_delta obs.lon, -97.0, 0.01
assert obs.pixel_count == 0
end
test "HTTP 500 returns {:error, _} with status message" do
Req.Test.stub(NexradClient, fn conn ->
Plug.Conn.send_resp(conn, 500, "boom")