defmodule Microwaveprop.SpaceWeather.SwpcClientTest do use ExUnit.Case, async: true 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 end end