prop/test/microwaveprop/space_weather/swpc_client_test.exs
Graham McIntire cb8445f329
fix: resolve 158/183 credo --strict warnings
- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg)
- Move 12+ nested alias/import/require to module top level
- Add phx-change/id attributes to 11 raw HTML <form> tags
- Remove 4 unused LiveView assigns (:bounds, :data_provider)
- Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts)
- Break 2 long lines (path_compute.ex:382)
- Strengthen weak test assertions (is_binary→byte_size, is_list→!=[])
- Replace Module.concat with Module.safe_concat (2 occurrences)
- Replace length/1 > 0 with list != [] (9 occurrences)
- Remove no-op assert true, fix no-assertion tests

Remaining: 24 socket.assigns introspection warnings (deliberate test
pattern for observable behavior testing), 1 formatter-resistant long
line, 3 app-code usage warnings.
2026-06-12 15:47:15 -05:00

281 lines
9.4 KiB
Elixir

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 byte_size(reason) > 0
# 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 x 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?({tag, _} when tag in [:ok, :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?({tag, _} when tag in [:ok, :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