- Schema: Era5Profile → NarrProfile; table renamed via migration rename_era5_profiles_to_narr_profiles (ALTER TABLE + rename indexes, atomic and instant, no row rewrite) - Weather helpers: find_nearest_era5/era5_for_contact/era5_profiles_for_path → find_nearest_narr/narr_for_contact/narr_profiles_for_path - BackfillEnqueueWorker: :era5 → :narr type (virtual, no narr_status column); reconcile_stale_queued_to_unavailable and status_priority_order now skip virtual types via @virtual_types. Fixes the prod crash where the cron tried to update a non-existent era5_status column. - ContactWeatherEnqueueWorker: build_era5_jobs → build_narr_jobs, era5_jobs_for_contact → narr_jobs_for_contact - ContactLive: @era5 → @narr, maybe_enqueue_era5 → maybe_enqueue_narr; UI label "ERA5 (0.25°)" → "NARR (32 km)" - Cron (runtime.exs): args types list now "narr" instead of "era5" - /status: progress row, status-by-type card, totals.narr_profiles, and table-count lookup all target narr_profiles - Drop obsolete Era5CdsJob schema + era5_cds_jobs table (inspection artifact from the retired CDS pipeline; 34 orphan rows in prod) - Misc docstring/comment cleanups (skew_t, about, wgrib2, propagation_train) Includes a regression test for the virtual-type crash.
143 lines
4.5 KiB
Elixir
143 lines
4.5 KiB
Elixir
defmodule Microwaveprop.Workers.NarrFetchWorkerTest do
|
|
use Microwaveprop.DataCase, async: false
|
|
use Oban.Testing, repo: Microwaveprop.Repo
|
|
|
|
alias Microwaveprop.Weather.NarrClient
|
|
alias Microwaveprop.Weather.NarrProfile
|
|
alias Microwaveprop.Workers.NarrFetchWorker
|
|
|
|
@inv_fixture "test/fixtures/narr/narr-a_221_20100615_1200_000.inv"
|
|
@grb_fixture "test/fixtures/narr/narr_dfw_2010-06-15_12z.grb"
|
|
@file_size 56_221_430
|
|
|
|
# Stub NarrClient HTTP so `fetch_profile_at/2` returns the spike fixture
|
|
# composite without hitting NCEI.
|
|
defp stub_narr_success do
|
|
inv_body = File.read!(@inv_fixture)
|
|
grb_body = File.read!(@grb_fixture)
|
|
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
cond do
|
|
String.ends_with?(conn.request_path, ".inv") and conn.method == "GET" ->
|
|
Plug.Conn.send_resp(conn, 200, inv_body)
|
|
|
|
String.ends_with?(conn.request_path, ".grb") and conn.method == "HEAD" ->
|
|
conn
|
|
|> Plug.Conn.put_resp_header("content-length", Integer.to_string(@file_size))
|
|
|> Plug.Conn.send_resp(200, "")
|
|
|
|
String.ends_with?(conn.request_path, ".grb") and conn.method == "GET" ->
|
|
Plug.Conn.send_resp(conn, 200, grb_body)
|
|
|
|
true ->
|
|
Plug.Conn.send_resp(conn, 500, "unexpected #{conn.method} #{conn.request_path}")
|
|
end
|
|
end)
|
|
end
|
|
|
|
describe "perform/1" do
|
|
test "fetches a profile via NarrClient and inserts it as a NarrProfile" do
|
|
stub_narr_success()
|
|
|
|
valid_time = ~U[2010-06-15 12:00:00Z]
|
|
|
|
args = %{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"valid_time" => DateTime.to_iso8601(valid_time)
|
|
}
|
|
|
|
assert :ok = NarrFetchWorker.perform(%Oban.Job{args: args})
|
|
|
|
# Row snapped to 0.25° grid (32.9 → 33.0, -97.0 → -97.0) and stored at
|
|
# the exact 3-hourly valid_time.
|
|
assert [row] = Repo.all(NarrProfile)
|
|
assert row.lat == 33.0
|
|
assert row.lon == -97.0
|
|
assert DateTime.compare(row.valid_time, valid_time) == :eq
|
|
assert_in_delta row.surface_temp_c, 23.79, 1.0
|
|
assert_in_delta row.surface_dewpoint_c, 21.34, 1.0
|
|
assert_in_delta row.surface_pressure_mb, 993.84, 2.0
|
|
assert_in_delta row.hpbl_m, 703.5, 10.0
|
|
assert_in_delta row.pwat_mm, 45.1, 2.0
|
|
end
|
|
|
|
test "is a no-op when a NarrProfile row already exists for the snapped lat/lon/time" do
|
|
valid_time = ~U[2010-06-15 12:00:00Z]
|
|
|
|
# Stub that raises if called — this test asserts no HTTP happens.
|
|
Req.Test.stub(NarrClient, fn _conn ->
|
|
raise "NarrClient should not be contacted when cache hit"
|
|
end)
|
|
|
|
# Pre-populate with a row at the snapped coordinates.
|
|
%NarrProfile{}
|
|
|> NarrProfile.changeset(%{
|
|
lat: 33.0,
|
|
lon: -97.0,
|
|
valid_time: valid_time,
|
|
profile: []
|
|
})
|
|
|> Repo.insert!()
|
|
|
|
args = %{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"valid_time" => DateTime.to_iso8601(valid_time)
|
|
}
|
|
|
|
assert :ok = NarrFetchWorker.perform(%Oban.Job{args: args})
|
|
|
|
# Only the pre-seeded row exists — no new insert.
|
|
assert Repo.aggregate(NarrProfile, :count, :id) == 1
|
|
end
|
|
|
|
test "returns {:error, _} when NarrClient.fetch_profile_at fails" do
|
|
Req.Test.stub(NarrClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "ncei server error")
|
|
end)
|
|
|
|
args = %{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"valid_time" => "2010-06-15T12:00:00Z"
|
|
}
|
|
|
|
assert {:error, _reason} = NarrFetchWorker.perform(%Oban.Job{args: args})
|
|
assert Repo.aggregate(NarrProfile, :count, :id) == 0
|
|
end
|
|
|
|
test "raises ArgumentError when valid_time is not on a 3-hourly NARR slot" do
|
|
# 13:00 is NOT a NARR analysis hour (only 00/03/06/09/12/15/18/21).
|
|
args = %{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"valid_time" => "2010-06-15T13:00:00Z"
|
|
}
|
|
|
|
assert_raise ArgumentError, fn ->
|
|
NarrFetchWorker.perform(%Oban.Job{args: args})
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "unique constraint" do
|
|
test "duplicate enqueues collapse via Oban unique" do
|
|
args = %{
|
|
"lat" => 32.9,
|
|
"lon" => -97.0,
|
|
"valid_time" => "2010-06-15T12:00:00Z"
|
|
}
|
|
|
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
|
{:ok, job1} = Oban.insert(NarrFetchWorker.new(args))
|
|
{:ok, job2} = Oban.insert(NarrFetchWorker.new(args))
|
|
|
|
# Second insert should hit the unique constraint and return the
|
|
# existing job (same id) instead of creating a new one.
|
|
assert job1.id == job2.id
|
|
assert Repo.aggregate(Oban.Job, :count, :id) == 1
|
|
end)
|
|
end
|
|
end
|
|
end
|