prop/test/microwaveprop/space_weather/swpc_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

281 lines
9.4 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias Microwaveprop.SpaceWeather.SwpcClient
describe "parse_kp/1" do
test "decodes the SWPC planetary_k_index_1m fixture" do
body = File.read!("test/fixtures/swpc/kp_sample.json")
assert {:ok, rows} = SwpcClient.parse_kp(body)
assert length(rows) == 10
first = hd(rows)
assert first.valid_time == ~U[2026-04-15 13:49:00Z]
assert first.kp_index == 0
assert_in_delta first.estimated_kp, 0.33, 0.001
end
test "returns {:error, _} for malformed bodies" do
assert {:error, _} = SwpcClient.parse_kp("not json")
assert {:error, _} = SwpcClient.parse_kp(~s({"not": "an array"}))
end
end
describe "fetch_xrays/0 upstream truncation" do
# SWPC occasionally returns a truncated JSON body (TCP reset mid-stream).
# Req's auto-decoder raises a Jason.DecodeError carrying the entire
# raw body in :data — historically that dumped hundreds of KB into
# our Logger. The error should be returned, but formatted short.
test "returns {:error, reason} without dumping the full truncated body" do
big_body = "[" <> String.duplicate(~s({"time_tag":"2026-04-20T21:35:00Z","energy":"0.1-0.8nm","flux":1.0},), 20_000)
# Truncate — no closing bracket.
truncated = big_body
Req.Test.stub(SwpcClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, truncated)
end)
assert {:error, reason} = SwpcClient.fetch_xrays()
assert is_binary(reason)
# Upper-bound the log-noise blast radius.
assert byte_size(reason) < 500
# Still descriptive.
assert reason =~ ~r/(decode|JSON|truncated|Jason)/i
end
end
describe "parse_f107/1" do
test "decodes the SWPC f107_cm_flux fixture" do
body = File.read!("test/fixtures/swpc/f107_sample.json")
assert {:ok, rows} = SwpcClient.parse_f107(body)
assert length(rows) == 10
first = hd(rows)
assert first.valid_time == ~U[2026-04-15 17:00:00Z]
assert first.frequency_mhz == 2800
assert first.flux_sfu == 112.0
assert first.reporting_schedule == "Morning"
end
test "keeps nullable historical stats as nil when absent" do
body = File.read!("test/fixtures/swpc/f107_sample.json")
{:ok, [first | _]} = SwpcClient.parse_f107(body)
assert first.ninety_day_mean == nil
end
end
describe "parse_xrays/1" do
test "decodes the GOES xrays-1-day fixture, keeping the 0.1-0.8nm band only" do
body = File.read!("test/fixtures/swpc/xrays_sample.json")
assert {:ok, rows} = SwpcClient.parse_xrays(body)
# Fixture has 12 records (6 timestamps × 2 bands). We keep the
# long-wavelength band (0.1-0.8nm) which is the standard SWF /
# D-layer absorption proxy.
assert length(rows) == 6
assert Enum.all?(rows, fn r -> r.energy_band == "0.1-0.8nm" end)
first = hd(rows)
assert first.valid_time == ~U[2026-04-14 19:49:00Z]
assert first.satellite == 18
assert_in_delta first.flux_wm2, 3.523e-07, 1.0e-09
end
test "filters out rows whose energy band isn't the long-wavelength one" do
body =
Jason.encode!([
%{
"time_tag" => "2026-04-14T19:49:00Z",
"satellite" => 18,
"flux" => 1.0e-7,
"energy" => "0.05-0.4nm"
},
%{
"time_tag" => "2026-04-14T19:49:00Z",
"satellite" => 18,
"flux" => 3.523e-7,
"energy" => "0.1-0.8nm"
}
])
assert {:ok, [row]} = SwpcClient.parse_xrays(body)
assert row.energy_band == "0.1-0.8nm"
end
test "flags electron_contaminated when the (misspelled upstream) key is truthy" do
body =
Jason.encode!([
%{
"time_tag" => "2026-04-14T19:49:00Z",
"satellite" => 18,
"flux" => 3.5e-7,
"energy" => "0.1-0.8nm",
"electron_contaminaton" => true
}
])
assert {:ok, [row]} = SwpcClient.parse_xrays(body)
assert row.electron_contaminated == true
end
test "silently drops rows whose time_tag won't parse" do
body =
Jason.encode!([
%{
"time_tag" => "not-a-timestamp",
"satellite" => 18,
"flux" => 3.5e-7,
"energy" => "0.1-0.8nm"
},
%{
"time_tag" => "2026-04-14T19:49:00Z",
"satellite" => 18,
"flux" => 3.5e-7,
"energy" => "0.1-0.8nm"
}
])
assert {:ok, [row]} = SwpcClient.parse_xrays(body)
assert row.valid_time == ~U[2026-04-14 19:49:00Z]
end
end
describe "parse_* shared helpers" do
test "parse_kp drops rows whose time_tag is unparseable" do
body =
Jason.encode!([
%{"time_tag" => "garbage", "kp_index" => 1},
%{"time_tag" => "2026-04-15T13:49:00", "kp_index" => 3, "estimated_kp" => 3.67}
])
assert {:ok, [row]} = SwpcClient.parse_kp(body)
assert row.kp_index == 3
assert row.valid_time == ~U[2026-04-15 13:49:00Z]
end
test "parse_kp tolerates integer and string numerics for kp_index" do
body =
Jason.encode!([
%{"time_tag" => "2026-04-15T13:49:00", "kp_index" => "4", "estimated_kp" => "4.33"},
%{"time_tag" => "2026-04-15T13:50:00", "kp_index" => 5, "estimated_kp" => 5.0}
])
assert {:ok, rows} = SwpcClient.parse_kp(body)
assert Enum.map(rows, & &1.kp_index) == [4, 5]
assert Enum.map(rows, & &1.estimated_kp) == [4.33, 5.0]
end
test "parse_f107 carries frequency, flux, schedule, and ninety_day_mean" do
body =
Jason.encode!([
%{
"time_tag" => "2026-04-15T17:00:00",
"frequency" => 2800,
"flux" => 142.4,
"reporting_schedule" => "Morning",
"ninety_day_mean" => 150.0
}
])
assert {:ok, [row]} = SwpcClient.parse_f107(body)
assert row.frequency_mhz == 2800
assert row.flux_sfu == 142.4
assert row.reporting_schedule == "Morning"
assert row.ninety_day_mean == 150.0
end
test "parse_* accept an already-decoded list body (Req auto-decode case)" do
list = [
%{"time_tag" => "2026-04-15T13:49:00", "kp_index" => 2, "estimated_kp" => 2.33}
]
# parse_kp is the representative here — the shared decode_array
# helper delegates the list path for every product.
assert {:ok, [row]} = SwpcClient.parse_kp(list)
assert row.kp_index == 2
end
test "parse_* return {:error, :not_a_list} for JSON objects" do
assert {:error, :not_a_list} = SwpcClient.parse_kp(~s({"not":"list"}))
assert {:error, :not_a_list} = SwpcClient.parse_f107(~s({"not":"list"}))
assert {:error, :not_a_list} = SwpcClient.parse_xrays(~s({"not":"list"}))
end
test "parse_kp returns {:ok, []} for an empty JSON array" do
assert {:ok, []} = SwpcClient.parse_kp("[]")
end
test "parse_f107 returns {:ok, []} for an empty JSON array" do
assert {:ok, []} = SwpcClient.parse_f107("[]")
end
test "parse_xrays returns {:ok, []} for an empty JSON array" do
assert {:ok, []} = SwpcClient.parse_xrays("[]")
end
test "parse_* reject bare non-list non-string inputs" do
assert {:error, :not_a_list} = SwpcClient.parse_kp(nil)
assert {:error, :not_a_list} = SwpcClient.parse_kp(123)
assert {:error, :not_a_list} = SwpcClient.parse_f107(:an_atom)
end
end
describe "parse_* totality property" do
property "parse_kp/1 never raises on arbitrary printable strings" do
# Malformed JSON must surface as a tagged {:ok, _} / {:error, _}
# tuple — the caller logs and moves on. A crash would kill the
# worker and blow past its retry budget.
check all(body <- string(:printable, max_length: 120)) do
result = SwpcClient.parse_kp(body)
assert match?({:ok, _}, result) or match?({:error, _}, result)
end
end
property "parse_xrays/1 never raises on arbitrary printable strings" do
check all(body <- string(:printable, max_length: 120)) do
result = SwpcClient.parse_xrays(body)
assert match?({:ok, _}, result) or match?({:error, _}, result)
end
end
end
describe "fetch_kp/0 HTTP errors" do
test "non-200 surfaces as {:error, 'SWPC HTTP ...'}" do
Req.Test.stub(SwpcClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("text/plain")
|> Plug.Conn.send_resp(503, "unavailable")
end)
assert {:error, reason} = SwpcClient.fetch_kp()
assert reason =~ "SWPC HTTP 503"
end
test "transport timeout surfaces as {:error, 'SWPC request failed: ...'}" do
Req.Test.stub(SwpcClient, fn conn ->
Req.Test.transport_error(conn, :timeout)
end)
assert {:error, reason} = SwpcClient.fetch_kp()
assert reason =~ "SWPC request failed"
end
end
describe "fetch_f107/0 HTTP errors" do
test "404 on the f107 endpoint is surfaced verbatim" do
Req.Test.stub(SwpcClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("text/plain")
|> Plug.Conn.send_resp(404, "gone")
end)
assert {:error, reason} = SwpcClient.fetch_f107()
assert reason =~ "SWPC HTTP 404"
end
end
end