- Beacon detail endpoints (LiveView + REST API) now hide unapproved beacons from anonymous and unauthorized viewers; only the submitter and admins can see pending records before approval. Adds Beacons.get_visible_beacon/2 with scope-aware checks. - API contact pagination now honors per_page end-to-end. Radio.list_contacts/1 accepts :per_page and clamps to 200. - API rate limiter: ETS table is now owned by a long-lived Sweeper GenServer (won't die with a request task); Sweeper periodically prunes expired-window rows to bound memory; init_table/0 race is rescued. - /scores/cells and /weather/cells: add per-IP rate limiting and a shared GridBounds clamp/413 guard so global / oversized viewports no longer drive unbounded binary responses. - NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte] + Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time for the 12200x5400 n0q frame goes from quadratic to linear. - LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch String.to_integer/String.to_float to Integer.parse/Float.parse, fall back to defaults instead of raising. - PathLive and MapLive band-event handlers: replace String.to_integer(params["band"]) with parse_int / parse_band_param so a non-numeric band parameter no longer crashes the LiveView.
182 lines
5.4 KiB
Elixir
182 lines
5.4 KiB
Elixir
defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
|
use MicrowavepropWeb.ConnCase, async: false
|
|
|
|
alias Microwaveprop.Propagation.ProfilesFile
|
|
alias Microwaveprop.Weather.GridCache
|
|
alias Microwaveprop.Weather.ScalarFile
|
|
|
|
setup do
|
|
GridCache.clear()
|
|
|
|
on_exit(fn ->
|
|
GridCache.clear()
|
|
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
describe "GET /weather/cells" do
|
|
test "returns 400 for missing or invalid params", %{conn: conn} do
|
|
conn = get(conn, ~p"/weather/cells?time=not-a-time")
|
|
assert json_response(conn, 400)
|
|
end
|
|
|
|
test "returns the binary cell pack with all default layers when ?layers is absent", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.5&north=33.5&west=-97.5&east=-96.5"
|
|
)
|
|
|
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
|
body = response(conn, 200)
|
|
assert <<"WCEL", 1::8, _cell_count::little-32, _layer_count::8, _rest::binary>> = body
|
|
end
|
|
|
|
test "with ?layers=foo,bar limits the binary pack to those layers", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
},
|
|
{33.125, -97.0} => %{
|
|
surface_temp_c: 23.0,
|
|
surface_dewpoint_c: 13.0,
|
|
surface_pressure_mb: 1011.0,
|
|
hpbl_m: 700.0,
|
|
pwat_mm: 26.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0&layers=temperature,pwat"
|
|
)
|
|
|
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
|
body = response(conn, 200)
|
|
|
|
assert <<"WCEL", 1::8, cell_count::little-32, 2::8, rest::binary>> = body
|
|
assert cell_count == 2
|
|
|
|
# Two layer names: "temperature" (11), "pwat" (4).
|
|
<<11::8, "temperature", 4::8, "pwat", padded_rest::binary>> = rest
|
|
|
|
# Header so far: 4(magic) + 1 + 4 + 1 + (1+11) + (1+4) = 27 bytes. One
|
|
# byte of zero padding aligns the f32 arrays to a 4-byte boundary.
|
|
<<0::8, lats_and_more::binary>> = padded_rest
|
|
|
|
<<lat1::little-float-32, lat2::little-float-32, _rest::binary>> = lats_and_more
|
|
assert_in_delta lat1, 33.0, 0.001
|
|
assert_in_delta lat2, 33.125, 0.001
|
|
end
|
|
|
|
test "returns an empty pack when no data exists for the valid_time", %{conn: conn} do
|
|
valid_time = ~U[2099-04-28 12:00:00Z]
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=32.0&north=34.0&west=-98.0&east=-96.0"
|
|
)
|
|
|
|
assert ["application/octet-stream"] = get_resp_header(conn, "content-type")
|
|
body = response(conn, 200)
|
|
assert <<"WCEL", 1::8, 0::little-32, _layer_count::8, _rest::binary>> = body
|
|
end
|
|
|
|
test "with ?source=hrdps returns only HRDPS rows (skips HRRR scalars and ProfilesFile)", %{conn: conn} do
|
|
valid_time = ~U[2026-04-28 12:00:00Z]
|
|
|
|
# HRRR side: write a Texas row via the standard pipeline.
|
|
ProfilesFile.write!(valid_time, %{
|
|
{33.0, -97.0} => %{
|
|
surface_temp_c: 22.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1010.0,
|
|
hpbl_m: 800.0,
|
|
pwat_mm: 25.0,
|
|
profile: []
|
|
}
|
|
})
|
|
|
|
# HRDPS side: hand-write a Canadian chunk into the HRDPS sibling dir.
|
|
hrdps_dir = ScalarFile.dir_for_hrdps(valid_time)
|
|
File.mkdir_p!(hrdps_dir)
|
|
lat_band = (53.0 / 5) |> Float.floor() |> trunc()
|
|
lon_band = (-60.0 / 5) |> Float.floor() |> trunc()
|
|
path = Path.join(hrdps_dir, "#{lat_band}_#{lon_band}.mp.gz")
|
|
|
|
payload =
|
|
Msgpax.pack!(
|
|
[
|
|
%{
|
|
"lat" => 53.0,
|
|
"lon" => -60.0,
|
|
"valid_time" => "2026-04-28T12:00:00Z",
|
|
"temperature" => 8.0,
|
|
"surface_rh" => 70.0
|
|
}
|
|
],
|
|
iodata: false
|
|
)
|
|
|
|
File.write!(path, :zlib.gzip(payload))
|
|
|
|
time = DateTime.to_iso8601(valid_time)
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=40.0&north=70.0&west=-150.0&east=-50.0&source=hrdps"
|
|
)
|
|
|
|
body = response(conn, 200)
|
|
assert <<"WCEL", 1::8, cell_count::little-32, _rest::binary>> = body
|
|
# Only the Canadian (53, -60) row — HRRR's (33, -97) is excluded.
|
|
assert cell_count == 1
|
|
|
|
File.rm_rf!(hrdps_dir)
|
|
end
|
|
|
|
test "returns 413 for global / oversized viewport bounds", %{conn: conn} do
|
|
time = DateTime.to_iso8601(~U[2026-04-28 12:00:00Z])
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
~p"/weather/cells?time=#{time}&south=-90&north=90&west=-180&east=180"
|
|
)
|
|
|
|
body = json_response(conn, 413)
|
|
assert body["error"] == "viewport_too_large"
|
|
end
|
|
end
|
|
end
|