prop/test/microwaveprop/weather/rtma_client_test.exs
Graham McIntire c514626a62
test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents.

- ContactLive.Show round 3 (72% → ~80%): enrichment failure
  surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR
  fallback), radar-only mechanism, load_solar + enqueue_missing_solar
  paths, blocked_message nil obs_dist, band_summary for 144M/1296M/
  2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts
  with seeded Oban jobs. Properties: propagation_mechanism summary is
  always a binary, obs_sort_key totality over arbitrary field keys.

- Weather clients: iem_client transport timeouts + CSV edge cases,
  rtma_client idx url property, swpc_client HTTP 503/404 + empty-
  array parses + JSON/CSV totality properties, ncei_metar_client
  parse edge cases, snmp_client OID-roundtrip property + parse
  edge cases.

- Mid-coverage LiveViews: UserProfileLive avatar/render branches,
  EmeLive invalid-grid/unknown-callsign + form-validation property,
  PathLive zero-distance + 3000km + height/power round-trip property,
  BeaconLive numeric bearing + non-admin approve denied, MapLive
  select_band/toggle_radar/toggle_grid events + band-URL property.

Fix: propagation_mechanism property test uses System.unique_integer
for lat + valid_time nonces instead of StreamData-shrinkable jitter;
the shrink toward 0 was tripping the hrrr_profiles unique index.

205 → 215 properties, 2743 → 2812 tests, 0 failures.
2026-04-24 10:15:37 -05:00

265 lines
9.5 KiB
Elixir

defmodule Microwaveprop.Weather.RtmaClientTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Microwaveprop.Weather.RtmaClient
describe "rtma_url/1" do
test "builds the S3 URL for an RTMA analysis time" do
vt = ~U[2026-04-15 18:00:00Z]
assert RtmaClient.rtma_url(vt) ==
"https://noaa-rtma-pds.s3.amazonaws.com/rtma2p5.20260415/rtma2p5.t18z.2dvaranl_ndfd.grb2_wexp"
end
test "zero-pads single-digit hours" do
vt = ~U[2026-04-15 03:00:00Z]
assert RtmaClient.rtma_url(vt) ==
"https://noaa-rtma-pds.s3.amazonaws.com/rtma2p5.20260415/rtma2p5.t03z.2dvaranl_ndfd.grb2_wexp"
end
test "handles hour zero" do
vt = ~U[2026-04-15 00:00:00Z]
assert RtmaClient.rtma_url(vt) ==
"https://noaa-rtma-pds.s3.amazonaws.com/rtma2p5.20260415/rtma2p5.t00z.2dvaranl_ndfd.grb2_wexp"
end
end
describe "fetch_observation/3 — idx layer" do
test "returns {:error, _} when the idx fetch returns 404 (not yet published)" do
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 404, "not found")
else
Plug.Conn.send_resp(conn, 500, "idx should have short-circuited")
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
assert reason =~ "RTMA idx HTTP 404"
end
test "returns {:error, _} when the idx fetch returns a non-200 status" do
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 503, "unavailable")
else
Plug.Conn.send_resp(conn, 500, "unexpected")
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
assert reason =~ "RTMA idx HTTP 503"
end
end
describe "fetch_observation/3 — hour truncation" do
test "truncates minutes and seconds to the analysis hour in the request URL" do
test_pid = self()
Req.Test.stub(RtmaClient, fn conn ->
send(test_pid, {:request_path, conn.request_path, conn.method})
# Return an empty idx so downstream stages short-circuit on 0 ranges.
Plug.Conn.send_resp(conn, 200, "")
end)
# 18:47:33 must request the 18z (not 19z) analysis file — truncation
# is load-bearing for matching the stored row's hour-aligned valid_time.
assert {:error, _} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:47:33Z])
assert_received {:request_path, path, "GET"}
assert String.contains?(path, "rtma2p5.t18z")
refute String.contains?(path, "rtma2p5.t19z")
end
test "already-aligned hour passes through unchanged" do
test_pid = self()
Req.Test.stub(RtmaClient, fn conn ->
send(test_pid, {:request_path, conn.request_path})
Plug.Conn.send_resp(conn, 200, "")
end)
assert {:error, _} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 12:00:00Z])
assert_received {:request_path, path}
assert String.contains?(path, "rtma2p5.t12z")
end
end
describe "fetch_observation/3 — extract layer" do
# With an empty (or non-matching) idx body, `byte_ranges_for_fields/2`
# produces zero ranges, so `download_ranges/2` does no HTTP and returns
# {:ok, ""}, which the GRIB2 Extractor splits into an empty message
# list → {:ok, %{}} → {:error, "RTMA: no data..."} path.
test "returns {:error, 'RTMA: no data ...'} when the extractor yields no fields" do
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
# idx contains no wanted fields — produces zero byte-ranges.
Plug.Conn.send_resp(conn, 200, "1:0:d=2026041518:FOOBAR:nothing:anl:\n")
else
# Should not be reached because no ranges means no GET.
Plug.Conn.send_resp(conn, 500, "unexpected GRB request")
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
assert reason =~ "RTMA: no data"
# rlat/rlon are reported to 2.5 km precision (1/40°).
assert reason =~ "32.9"
assert reason =~ "-97.0"
end
test "returns {:error, 'RTMA: no data ...'} when grib bytes are non-GRIB garbage" do
# Serve an idx that DOES match wanted fields so byte-ranges are non-empty,
# then serve junk bytes on the ranged GET. The GRIB2 Extractor's
# split_messages/1 skips unknown bytes and returns an empty message list,
# so extract_point still yields the "no data" error.
idx = """
1:0:d=2026041518:TMP:2 m above ground:anl:
2:100:d=2026041518:DPT:2 m above ground:anl:
3:200:d=2026041518:PRES:surface:anl:
"""
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 200, idx)
else
# 206 Partial Content with non-GRIB junk.
Plug.Conn.send_resp(conn, 206, :binary.copy(<<0>>, 1024))
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
assert reason =~ "RTMA: no data"
end
end
describe "fetch_observation/3 — malformed idx" do
test "crashes on a malformed idx line with a non-integer offset" do
# byte_ranges_for_fields/2 calls String.to_integer/1 on the offset
# field without a guard — a non-numeric offset is a programmer/server
# contract violation and the process raises. Characterize this so
# any future change to forgiving-parsing is intentional.
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 200, "1:notanumber:d=2026041518:TMP:2 m above ground:anl:\n")
else
Plug.Conn.send_resp(conn, 500, "unexpected")
end
end)
assert_raise ArgumentError, fn ->
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
end
end
test "tolerates an idx line with missing trailing fields (uses empty field name)" do
# A single-field line falls back to empty strings for var/level via
# Enum.at/3 defaults, so it simply doesn't match any wanted field.
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 200, "1:0\n")
else
Plug.Conn.send_resp(conn, 500, "unexpected")
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
# Empty idx body → zero ranges → empty GRIB → "no data" at extraction.
assert reason =~ "RTMA: no data"
end
end
describe "fetch_observation/3 — download layer" do
# download_ranges/2's Task.async_stream reduces over results; any
# non-200/206 downgrades the accumulator to {:error, _}. But a catch-all
# `_, acc -> acc` clause in the reducer silently swallows :exit/:timeout
# task results. If all tasks time out, the accumulator stays {:ok, []},
# which produces an empty binary and ultimately "RTMA: no data ...".
# Characterizing, not prescribing — tighten in a follow-up if desired.
test "surfaces a non-200 status from the ranged GET as an error" do
idx = """
1:0:d=2026041518:TMP:2 m above ground:anl:
2:100:d=2026041518:DPT:2 m above ground:anl:
"""
Req.Test.stub(RtmaClient, fn conn ->
if String.ends_with?(conn.request_path, ".idx") do
Plug.Conn.send_resp(conn, 200, idx)
else
Plug.Conn.send_resp(conn, 500, "s3 blew up")
end
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
assert reason =~ "RTMA download HTTP 500"
end
test "surfaces a transport timeout on the idx GET as an {:error, _}" do
Req.Test.stub(RtmaClient, fn conn ->
Req.Test.transport_error(conn, :timeout)
end)
assert {:error, reason} =
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
# fetch_idx wraps transport errors into a human-readable string.
assert reason =~ "RTMA idx failed"
end
end
describe "rtma_url/1 round-trip" do
property "embeds YYYYMMDD and a zero-padded hour for any valid analysis time" do
check all(
year <- integer(2020..2030),
month <- integer(1..12),
day <- integer(1..28),
hour <- integer(0..23)
) do
dt = %DateTime{
year: year,
month: month,
day: day,
hour: hour,
minute: 0,
second: 0,
microsecond: {0, 0},
std_offset: 0,
utc_offset: 0,
zone_abbr: "UTC",
time_zone: "Etc/UTC"
}
url = RtmaClient.rtma_url(dt)
date_str =
Integer.to_string(year) <>
String.pad_leading(Integer.to_string(month), 2, "0") <>
String.pad_leading(Integer.to_string(day), 2, "0")
hour_str = String.pad_leading(Integer.to_string(hour), 2, "0")
assert String.contains?(url, "rtma2p5.#{date_str}/")
assert String.contains?(url, "rtma2p5.t#{hour_str}z")
assert String.ends_with?(url, ".grb2_wexp")
end
end
end
end