defmodule Microwaveprop.Workers.GefsFetchWorkerTest do use Microwaveprop.DataCase, async: false use ExUnitProperties alias Microwaveprop.Weather alias Microwaveprop.Weather.GefsProfile alias Microwaveprop.Workers.GefsFetchWorker describe "perform/1" do test "cancels on malformed (non-empty, not a seed) args" do args = %{"some_unrelated" => "garbage"} assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "cancels when run_hour is not a valid GEFS cycle" do args = %{ "run_time" => "2026-04-18T05:00:00Z", "forecast_hour" => 24 } assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args}) end end describe "build_profile_attrs/3" do test "merges lat/lon/valid_time onto a GefsClient profile map" do run_time = ~U[2026-04-18 12:00:00Z] profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, wind_u_mps: 2.0, wind_v_mps: -1.0, cloud_cover_pct: 50.0, precip_mm: 0.0, profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}] } attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) assert attrs.run_time == run_time assert attrs.forecast_hour == 24 assert attrs.valid_time == ~U[2026-04-19 12:00:00Z] assert attrs.lat == 32.9 assert attrs.surface_temp_c == 20.0 end test "derives SoundingParams-style refractivity metrics when profile is populated" do run_time = ~U[2026-04-18 12:00:00Z] profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 15.0, surface_pressure_mb: 1013.0, pwat_mm: 25.0, profile: [ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}, %{"pres" => 925.0, "tmpc" => 18.0, "dwpc" => 12.0, "hght" => 770.0}, %{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 1500.0} ] } attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) assert is_float(attrs.surface_refractivity) assert is_float(attrs.min_refractivity_gradient) assert is_boolean(attrs.ducting_detected) end end describe "extended_horizon_hours/0" do test "spans f024 through f168 at 6-hour cadence (25 entries)" do hours = GefsFetchWorker.extended_horizon_hours() assert List.first(hours) == 24 assert List.last(hours) == 168 assert length(hours) == 25 assert Enum.all?(hours, &(rem(&1, 6) == 0)) end end describe "most_recent_available_run/1" do test "returns the run cycle 5 or more hours earlier" do # 15:00 UTC → 10:00 UTC → snaps to 06Z assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 15:00:00Z]) == ~U[2026-04-18 06:00:00Z] end test "rolls across midnight when now is near 04Z" do # 04:00 UTC → 23:00 the day before → snaps to 18Z previous day assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 04:00:00Z]) == ~U[2026-04-17 18:00:00Z] end end describe "scoring interop" do alias Microwaveprop.Propagation alias Microwaveprop.Weather.GefsClient test "a GEFS-shaped profile produces non-empty band scores" do raw = %{ "TMP:2 m above ground" => 293.15, "RH:2 m above ground" => 50.0, "PRES:surface" => 101_325.0, "PWAT:entire atmosphere (considered as a single layer)" => 20.0, "UGRD:10 m above ground" => 3.0, "VGRD:10 m above ground" => -1.5, "TCDC:entire atmosphere" => 40.0, "APCP:surface" => 0.0, "TMP:1000 mb" => 293.15, "RH:1000 mb" => 50.0, "HGT:1000 mb" => 110.0, "TMP:925 mb" => 288.15, "RH:925 mb" => 40.0, "HGT:925 mb" => 780.0, "TMP:850 mb" => 283.15, "RH:850 mb" => 35.0, "HGT:850 mb" => 1500.0 } scores = raw |> GefsClient.build_profile() |> Propagation.score_grid_point(~U[2026-04-20 18:00:00Z], 32.9, -97.0) assert is_list(scores) assert scores != [] score = hd(scores) assert is_integer(score.band_mhz) assert is_integer(score.score) assert score.score >= 0 assert score.score <= 100 end end describe "most_recent_available_run/1 (further cases)" do test "is always 5+ hours older than the input time, on a GEFS cycle" do for iso <- ~w(2026-04-18T00:00:00Z 2026-04-18T06:00:00Z 2026-04-18T11:59:59Z) do {:ok, now, _} = DateTime.from_iso8601(iso) run = GefsFetchWorker.most_recent_available_run(now) assert DateTime.diff(now, run, :second) >= 5 * 3600 assert run.hour in [0, 6, 12, 18] end end end describe "perform/1 seed path (empty args)" do test "empty-args job enqueues one GefsFetchWorker per extended_horizon hour" do Oban.Testing.with_testing_mode(:manual, fn -> assert :ok = GefsFetchWorker.perform(%Oban.Job{args: %{}}) # 25 × f024..f168 at 6-hour steps. jobs = Microwaveprop.Repo.all( Ecto.Query.from(j in Oban.Job, where: j.worker == "Microwaveprop.Workers.GefsFetchWorker") ) # Seeder enqueues one job per forecast hour (plus the run-time row # per cycle). Count should equal extended_horizon_hours length. assert length(jobs) == length(GefsFetchWorker.extended_horizon_hours()) end) end end describe "perform/1 error handling via a stubbed GefsClient" do # Driving the real perform/1 through an HTTP stub ends up calling # out through the GefsClient module. A 503 from NOMADS is classified # as transient (→ {:error, reason}) while a 404 is permanent (→ # {:cancel, reason}). These exercise handle_error/2 end-to-end. test "a transient HTTP 503 returns {:error, _} so Oban retries the job" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 503, "Service Unavailable") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "a permanent 404 returns {:cancel, _} so Oban drops the job" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 404, "Not Found") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end end describe "storing profiles" do test "round-trips a full batch through the context" do run_time = ~U[2026-04-18 12:00:00Z] fh = 24 valid_time = DateTime.add(run_time, fh * 3600, :second) batch = [ %{ run_time: run_time, forecast_hour: fh, valid_time: valid_time, lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, profile: [] }, %{ run_time: run_time, forecast_hour: fh, valid_time: valid_time, lat: 33.0, lon: -97.0, surface_temp_c: 19.5, surface_dewpoint_c: 9.5, surface_pressure_mb: 1012.5, pwat_mm: 19.0, profile: [] } ] {count, _} = Weather.upsert_gefs_profiles_batch(batch) assert count == 2 persisted = Microwaveprop.Repo.all(GefsProfile) assert length(persisted) == 2 end end describe "perform/1 HTTP classification branches" do test "a transient HTTP 500 returns {:error, _} so Oban retries" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 500, "Internal Server Error") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "a transient HTTP 429 (rate-limit) returns {:error, _}" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 429, "Too Many Requests") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "a permanent HTTP 403 returns {:cancel, _}" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 403, "Forbidden") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "malformed run_time ISO8601 returns {:cancel, :invalid_args}" do args = %{"run_time" => "not-a-timestamp", "forecast_hour" => 24} assert {:cancel, :invalid_args} = GefsFetchWorker.perform(%Oban.Job{args: args}) end end describe "build_profile_attrs/3 with empty profile" do test "an empty profile list leaves derived refractivity fields absent" do run_time = ~U[2026-04-18 12:00:00Z] profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, profile: [] } attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) # With an empty levels list, SoundingParams.derive returns nil and the # base attrs map is returned untouched — no refractivity keys. refute Map.has_key?(attrs, :surface_refractivity) refute Map.has_key?(attrs, :ducting_detected) end end describe "perform/1 HTTP classification extra boundaries" do test "a transient HTTP 502 (bad gateway) returns {:error, _}" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 502, "Bad Gateway") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "a permanent HTTP 400 (bad request) returns {:cancel, _}" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 400, "Bad Request") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end test "a permanent HTTP 410 (gone) returns {:cancel, _}" do Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> Plug.Conn.send_resp(conn, 410, "Gone") end) args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) end end describe "build_profile_attrs/3 extra branches" do test "accepts wind_u / wind_v keys without the _mps suffix" do # `build_profile_attrs` checks both `wind_u` and `wind_u_mps` — the # non-suffixed keys should also populate the row attrs. run_time = ~U[2026-04-18 12:00:00Z] profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, wind_u: 4.5, wind_v: -2.25, profile: [] } attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) assert attrs.wind_u_mps == 4.5 assert attrs.wind_v_mps == -2.25 end test "preserves a nil profile list as a no-derivation base map" do run_time = ~U[2026-04-18 12:00:00Z] profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, profile: nil } attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile) # nil profile → `SoundingParams.derive([])` is called with the `|| []` # fallback; empty list returns nil so no derived keys are added. refute Map.has_key?(attrs, :surface_refractivity) assert attrs.lat == 32.9 end end # Property: build_profile_attrs/3 always emits a valid_time exactly # `forecast_hour * 3600` seconds after the run_time, for any run/fh pair. property "build_profile_attrs/3 valid_time is run_time + fh hours exactly" do check all( epoch <- StreamData.integer(1_700_000_000..1_800_000_000), fh <- StreamData.integer(0..384) ) do {:ok, run_time} = DateTime.from_unix(epoch) run_time = DateTime.truncate(run_time, :second) profile = %{ lat: 32.9, lon: -97.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1013.0, pwat_mm: 20.0, profile: [] } attrs = GefsFetchWorker.build_profile_attrs(run_time, fh, profile) assert DateTime.diff(attrs.valid_time, attrs.run_time, :second) == fh * 3600 assert attrs.forecast_hour == fh end end # Property: the seeder's target run is always a valid GEFS cycle at # least 5 hours older than the input instant, regardless of time of day. property "most_recent_available_run/1 snaps to a valid 00/06/12/18Z cycle" do check all(epoch <- StreamData.integer(1_700_000_000..1_800_000_000)) do {:ok, now} = DateTime.from_unix(epoch) run = GefsFetchWorker.most_recent_available_run(now) assert run.hour in [0, 6, 12, 18] assert run.minute == 0 assert run.second == 0 assert DateTime.diff(now, run, :second) >= 5 * 3600 end end end