Two unrelated production warnings from the same log window: WeatherFetchWorker raised Postgrex 21000 cardinality_violation on ASOS upserts when IEM returned two METAR records for the same timestamp (routine + special at the same minute). Dedupe by (station_id, observed_at) in upsert_surface_observations, keeping the last occurrence since IEM's ordering is chronological. SwpcClient.fetch_xrays warned with the full truncated response body (~650 KB of GOES X-ray JSON) dumped into the log aggregator whenever SWPC's CDN cut the response mid-stream. Intercept Jason.DecodeError in fetch_and_parse and format it short: keep the byte position and an 80-char preview, drop the rest.
88 lines
3.1 KiB
Elixir
88 lines
3.1 KiB
Elixir
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
|
||
end
|
||
end
|