Pure-function invariants that example tests can't express at scale: - Weather.round_to_iemre_grid/2, HrrrClient.nearest_hrrr_hour/1, NexradClient.round_to_5min/1, NarrClient.snap_to_analysis_hour/1: each is idempotent, its output satisfies a grid/slot-membership predicate, and lies within a bounded distance of the input. - NarrClient.in_coverage?/1: coverage_end is an exclusive upper bound; any timestamp after it is out of coverage. - IemreFetchWorker.backoff/1: result is in [120, 21_600], monotonic non-decreasing in attempt, equals 120*2^(attempt-1) pre-cap. - Contact submission_changeset notes field: every non-blank string up to 2000 chars validates; any longer string is rejected. 22 new properties (139 → 161 including existing); all 2,300 tests green, credo clean.
183 lines
5.1 KiB
Elixir
183 lines
5.1 KiB
Elixir
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
|