defmodule Microwaveprop.Workers.IemreFetchWorkerTest do use Microwaveprop.DataCase, async: true use ExUnitProperties alias Microwaveprop.Weather alias Microwaveprop.Weather.IemClient alias Microwaveprop.Weather.IemreObservation alias Microwaveprop.Workers.IemreFetchWorker @sample_iemre_data [ %{"utc_hour" => 0, "p01m_mm" => 0.0, "skyc_pct" => 25.0}, %{"utc_hour" => 1, "p01m_mm" => 0.5, "skyc_pct" => 50.0} ] describe "perform/1" do test "skips if IEMRE observation already exists" do Weather.upsert_iemre_observation(%{ lat: 32.875, lon: -97.0, date: ~D[2026-03-28], hourly: @sample_iemre_data }) # Stub should NOT be called Req.Test.stub(IemClient, fn conn -> Plug.Conn.send_resp(conn, 500, "Should not be called") end) job = %Oban.Job{ args: %{ "lat" => 32.875, "lon" => -97.0, "date" => "2026-03-28" } } assert :ok = IemreFetchWorker.perform(job) assert Repo.aggregate(IemreObservation, :count) == 1 end test "fetches and stores IEMRE data on success" do Req.Test.stub(IemClient, fn conn -> Req.Test.json(conn, %{"data" => @sample_iemre_data}) end) job = %Oban.Job{ args: %{ "lat" => 32.875, "lon" => -97.0, "date" => "2026-03-28" } } assert :ok = IemreFetchWorker.perform(job) assert Repo.aggregate(IemreObservation, :count) == 1 obs = Repo.one(IemreObservation) assert obs.lat == 32.875 assert obs.lon == -97.0 assert obs.date == ~D[2026-03-28] assert length(obs.hourly) == 2 end test "stores stub on empty data so backfill doesn't retry" do Req.Test.stub(IemClient, fn conn -> Req.Test.json(conn, %{"data" => []}) end) job = %Oban.Job{ args: %{ "lat" => 32.875, "lon" => -97.0, "date" => "2026-03-28" } } assert :ok = IemreFetchWorker.perform(job) assert Repo.aggregate(IemreObservation, :count) == 1 obs = Repo.one!(IemreObservation) assert obs.hourly == [] assert obs.lat == 32.875 assert obs.lon == -97.0 end test "retries on transient server error" do Req.Test.stub(IemClient, fn conn -> Plug.Conn.send_resp(conn, 503, "Service Unavailable") end) job = %Oban.Job{ args: %{ "lat" => 32.875, "lon" => -97.0, "date" => "2026-03-28" } } assert {:error, "IEM IEMRE HTTP 503"} = IemreFetchWorker.perform(job) end test "cancels on permanent failure (404) and stores stub so backfill reconciles" do Req.Test.stub(IemClient, fn conn -> Plug.Conn.send_resp(conn, 404, "not found") end) job = %Oban.Job{ args: %{ "lat" => 32.875, "lon" => -97.0, "date" => "2026-03-28" } } assert {:cancel, "IEM IEMRE HTTP 404"} = IemreFetchWorker.perform(job) assert Repo.aggregate(IemreObservation, :count) == 1 obs = Repo.one!(IemreObservation) assert obs.hourly == [] assert obs.lat == 32.875 assert obs.lon == -97.0 end test "cancels on permanent failure (422 out-of-grid) and stores stub" do Req.Test.stub(IemClient, fn conn -> Plug.Conn.send_resp(conn, 422, "out of grid") end) job = %Oban.Job{ args: %{ "lat" => 60.75, "lon" => -151.25, "date" => "2019-08-03" } } assert {:cancel, "IEM IEMRE HTTP 422"} = IemreFetchWorker.perform(job) assert Repo.aggregate(IemreObservation, :count) == 1 obs = Repo.one!(IemreObservation) assert obs.hourly == [] assert obs.lat == 60.75 assert obs.lon == -151.25 assert obs.date == ~D[2019-08-03] end end describe "backoff/1" do test "uses exponential backoff capped at 6 hours" do assert IemreFetchWorker.backoff(%Oban.Job{attempt: 1}) == 120 assert IemreFetchWorker.backoff(%Oban.Job{attempt: 2}) == 240 assert IemreFetchWorker.backoff(%Oban.Job{attempt: 10}) == 21_600 assert IemreFetchWorker.backoff(%Oban.Job{attempt: 20}) == 21_600 end property "is always in the [120, 21_600] interval" do check all(attempt <- integer(1..100)) do b = IemreFetchWorker.backoff(%Oban.Job{attempt: attempt}) assert b >= 120 assert b <= 21_600 end end property "is monotonic non-decreasing in attempt" do check all( a <- integer(1..50), delta <- integer(0..50) ) do b = a + delta assert IemreFetchWorker.backoff(%Oban.Job{attempt: a}) <= IemreFetchWorker.backoff(%Oban.Job{attempt: b}) end end property "matches 120 * 2^(attempt-1) before the cap kicks in" do # cap = 21_600 = 120 * 180; 2^7 = 128 < 180 < 256 = 2^8, so # attempts 1..7 are always pre-cap. check all(attempt <- integer(1..7)) do expected = 120 * Integer.pow(2, attempt - 1) assert IemreFetchWorker.backoff(%Oban.Job{attempt: attempt}) == expected end end end end