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