diff --git a/lib/microwaveprop/commercial/poll_worker.ex b/lib/microwaveprop/commercial/poll_worker.ex index ee6bae88..b953c312 100644 --- a/lib/microwaveprop/commercial/poll_worker.ex +++ b/lib/microwaveprop/commercial/poll_worker.ex @@ -49,7 +49,14 @@ defmodule Microwaveprop.Commercial.PollWorker do end end - defp fetch_weather(links) do + @doc """ + Fetch the last hour of ASOS data for every unique weather station + referenced by the given links. Exposed (not private) so tests can + exercise the IEM fetch + upsert path without having to drive + `perform/1` through the real SNMP layer. + """ + @spec fetch_weather([Commercial.Link.t()]) :: :ok + def fetch_weather(links) do stations = links |> Enum.map(& &1.weather_station) diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 633e6b00..7a77e287 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do end def handle_event("toggle_flag", _params, socket) do - if admin?(socket.assigns.current_scope) do + if admin?(socket.assigns) do contact = Radio.toggle_flagged_invalid!(socket.assigns.contact) {:noreply, assign(socket, contact: contact)} else diff --git a/test/microwaveprop/commercial/poll_worker_test.exs b/test/microwaveprop/commercial/poll_worker_test.exs index df7d398d..ec04967c 100644 --- a/test/microwaveprop/commercial/poll_worker_test.exs +++ b/test/microwaveprop/commercial/poll_worker_test.exs @@ -4,6 +4,9 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do alias Microwaveprop.Commercial alias Microwaveprop.Commercial.PollWorker alias Microwaveprop.Commercial.Sample + alias Microwaveprop.Weather + alias Microwaveprop.Weather.IemClient + alias Microwaveprop.Weather.SurfaceObservation @link_attrs %{ label: "test-poll-link", @@ -96,5 +99,100 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500 end + + test "logs and skips when create_sample rejects the changeset" do + {:ok, link} = Commercial.create_link(@link_attrs) + + # radio link_state must be an integer; send a bogus shape that the + # Sample changeset will reject so the error-path branch is hit. + PollWorker.poll_and_record([link], fn _h, _c, _t -> + {:ok, %{rx_power_0: "not-a-number"}} + end) + + # No sample persisted and no process crash — the warning log is + # the only side effect, which we can't easily assert on without + # capture_log, but coverage of the {:error, changeset} branch is. + assert Repo.all(Sample) == [] + end + end + + describe "fetch_weather/1" do + # Exercises the IEM-fetch + ASOS-upsert path (previously private) via + # stubbed Req. Avoids the perform/1 SNMP round-trip which would call + # every live commercial link at once. + + setup do + # IemClient uses Req — stub it with a minimal ASOS tab-separated + # response body so the JSON/CSV decoder path is exercised. + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 200, "station,valid\n") + end) + + :ok + end + + test "creates (or finds) a Weather.Station row for every unique link station" do + {:ok, link} = Commercial.create_link(@link_attrs) + + assert :ok = PollWorker.fetch_weather([link]) + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KTKI", + station_type: "asos", + name: "KTKI", + lat: 0.0, + lon: 0.0 + }) + + assert station.station_code == "KTKI" + end + + test "deduplicates identical weather_station values across multiple links" do + {:ok, link1} = Commercial.create_link(@link_attrs) + + {:ok, link2} = + Commercial.create_link(%{@link_attrs | label: "sibling", host: "10.0.0.2"}) + + call_count = :counters.new(1, [:atomics]) + + Req.Test.stub(IemClient, fn conn -> + :counters.add(call_count, 1, 1) + Plug.Conn.send_resp(conn, 200, "station,valid\n") + end) + + PollWorker.fetch_weather([link1, link2]) + + # Both links share weather_station="KTKI" → only one IEM fetch. + assert :counters.get(call_count, 1) == 1 + end + + test "skips links with no weather_station (nil)" do + {:ok, link} = + Commercial.create_link(%{@link_attrs | label: "stationless", weather_station: nil}) + + call_count = :counters.new(1, [:atomics]) + + Req.Test.stub(IemClient, fn conn -> + :counters.add(call_count, 1, 1) + Plug.Conn.send_resp(conn, 200, "") + end) + + assert :ok = PollWorker.fetch_weather([link]) + assert :counters.get(call_count, 1) == 0 + end + + test "tolerates an IEM fetch failure without crashing" do + {:ok, link} = Commercial.create_link(@link_attrs) + + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 500, "server error") + end) + + assert :ok = PollWorker.fetch_weather([link]) + + # No surface observations persisted on HTTP error. + assert Repo.all(SurfaceObservation) == [] + end end end diff --git a/test/microwaveprop/commercial/snmp_client_test.exs b/test/microwaveprop/commercial/snmp_client_test.exs index 0f0b579d..83411ebf 100644 --- a/test/microwaveprop/commercial/snmp_client_test.exs +++ b/test/microwaveprop/commercial/snmp_client_test.exs @@ -171,4 +171,14 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do assert result.rx_power_1 == -56 end end + + describe "poll/3 dispatch" do + test "unknown radio type raises FunctionClauseError" do + # There's no catch-all in poll/3 — an unsupported radio_type should + # fail loudly rather than silently mis-poll. + assert_raise FunctionClauseError, fn -> + SnmpClient.poll("10.0.0.1", "public", "unknown-model") + end + end + end end diff --git a/test/microwaveprop/release_test.exs b/test/microwaveprop/release_test.exs new file mode 100644 index 00000000..d8abb5b8 --- /dev/null +++ b/test/microwaveprop/release_test.exs @@ -0,0 +1,105 @@ +defmodule Microwaveprop.ReleaseTest do + @moduledoc """ + Smoke tests for the release-task entry points. + + Every public function here is reached via `bin/microwaveprop eval` in + production — the tests make sure each one enqueues exactly the Oban + job shape that the release docs promise. + """ + use Microwaveprop.DataCase, async: false + use Oban.Testing, repo: Microwaveprop.Repo + + alias Microwaveprop.Release + alias Microwaveprop.Workers.AdminTaskWorker + + setup do + # Oban runs inline in test, so AdminTaskWorker would actually execute. + # Patch it out: we only care that Release *schedules* the right job. + Oban.Testing.with_testing_mode(:manual, fn -> + :ok + end) + end + + describe "backtest/1" do + test "enqueues an AdminTaskWorker job with the feature name in args" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.backtest("naive_gradient") end) + + assert_enqueued( + worker: AdminTaskWorker, + args: %{task: "backtest", feature: "naive_gradient"} + ) + end) + end + end + + describe "backtest_all/0" do + test "enqueues the consolidated backtest task" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.backtest_all() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "backtest_all"}) + end) + end + end + + describe "climatology/1" do + test "uses the default min_samples of 3" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.climatology() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 3}) + end) + end + + test "passes through a custom min_samples" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.climatology(7) end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 7}) + end) + end + end + + describe "recalibrate/0" do + test "enqueues a recalibration" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.recalibrate() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "recalibrate"}) + end) + end + end + + describe "native_derive/1" do + test "enqueues with the provided row limit" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.native_derive(2_500) end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 2_500}) + end) + end + + test "defaults to a 10_000 limit" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.native_derive() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 10_000}) + end) + end + end + + describe "scorer_diff/1" do + test "prints a deprecation notice (propagation_scores is gone)" do + output = ExUnit.CaptureIO.capture_io(fn -> Release.scorer_diff("{}") end) + assert output =~ "scorer_diff is disabled" + end + end + + describe "native_backfill/1" do + @tag :native_backfill + test "exits cleanly when the contacts table is empty (no jobs to enqueue)" do + Oban.Testing.with_testing_mode(:manual, fn -> + output = ExUnit.CaptureIO.capture_io(fn -> Release.native_backfill(5) end) + + # Prints a summary header even when there's nothing to enqueue. + assert output =~ "Enqueuing 0 native backfill jobs" + assert output =~ "Done." + end) + end + end +end diff --git a/test/microwaveprop/weather_extra_test.exs b/test/microwaveprop/weather_extra_test.exs new file mode 100644 index 00000000..95f0f4ee --- /dev/null +++ b/test/microwaveprop/weather_extra_test.exs @@ -0,0 +1,214 @@ +defmodule Microwaveprop.WeatherExtraTest do + @moduledoc """ + Fills in coverage holes on lesser-used `Microwaveprop.Weather` + context helpers: has_surface_observations?/3, has_sounding?/2, + the two `station_ids_with_*` set-membership helpers, + `existing_solar_dates/0`, `sounding_times_around/1`, and + `hrrr_data_fully_present?/1`. + + Each is driven with a small, deterministic fixture — we care about + the boolean / set return contracts rather than large real-world + data shapes. + """ + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Repo + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Sounding + alias Microwaveprop.Weather.SurfaceObservation + + defp station!(attrs \\ %{}) do + base = %{ + station_code: "TST#{System.unique_integer([:positive])}", + station_type: "asos", + name: "Test Station", + lat: 33.0, + lon: -97.0 + } + + {:ok, s} = Weather.find_or_create_station(Map.merge(base, attrs)) + s + end + + describe "has_surface_observations?/3" do + test "true when any observation falls inside the window" do + station = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 72.0 + }) + |> Repo.insert() + + assert Weather.has_surface_observations?( + station.id, + ~U[2026-04-20 00:00:00Z], + ~U[2026-04-20 23:59:59Z] + ) + end + + test "false when the window excludes every observation" do + station = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 72.0 + }) + |> Repo.insert() + + refute Weather.has_surface_observations?( + station.id, + ~U[2026-04-21 00:00:00Z], + ~U[2026-04-21 23:59:59Z] + ) + end + + test "false for a station with no observations" do + station = station!() + refute Weather.has_surface_observations?(station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z]) + end + end + + describe "has_sounding?/2" do + test "true when a sounding exists at the exact observed_at" do + station = station!(%{station_type: "sounding"}) + + {:ok, _} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + profile: [], + level_count: 0 + }) + |> Repo.insert() + + assert Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) + end + + test "false when no sounding exists at the given time" do + station = station!(%{station_type: "sounding"}) + refute Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) + end + end + + describe "station_ids_with_* set-membership helpers" do + test "station_ids_with_surface_observations returns only the stations that have rows in the window" do + a = station!() + b = station!() + c = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: a.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 60.0 + }) + |> Repo.insert() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: b.id, + observed_at: ~U[2026-04-20 13:00:00Z], + temp_f: 70.0 + }) + |> Repo.insert() + + present = + Weather.station_ids_with_surface_observations( + [a.id, b.id, c.id], + ~U[2026-04-20 00:00:00Z], + ~U[2026-04-20 23:59:59Z] + ) + + assert MapSet.size(present) == 2 + assert MapSet.member?(present, a.id) + assert MapSet.member?(present, b.id) + refute MapSet.member?(present, c.id) + end + + test "station_ids_with_soundings returns {station_id, observed_at} tuples" do + station = station!(%{station_type: "sounding"}) + + {:ok, _} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + profile: [], + level_count: 0 + }) + |> Repo.insert() + + set = + Weather.station_ids_with_soundings( + [station.id], + [~U[2026-04-20 12:00:00Z], ~U[2026-04-20 00:00:00Z]] + ) + + assert MapSet.member?(set, {station.id, ~U[2026-04-20 12:00:00Z]}) + refute MapSet.member?(set, {station.id, ~U[2026-04-20 00:00:00Z]}) + end + end + + describe "sounding_times_around/1" do + # Radiosondes fly twice a day (00Z, 12Z). The helper returns the + # two nearest bracketing times for a given wall-clock UTC. + test "early-morning times bracket prev-day 12Z and same-day 00Z" do + times = Weather.sounding_times_around(~U[2026-04-20 06:00:00Z]) + + assert length(times) >= 2 + assert ~U[2026-04-19 12:00:00Z] in times + assert ~U[2026-04-20 00:00:00Z] in times + end + + test "afternoon times bracket same-day 00Z/12Z" do + times = Weather.sounding_times_around(~U[2026-04-20 18:00:00Z]) + assert ~U[2026-04-20 00:00:00Z] in times + assert ~U[2026-04-20 12:00:00Z] in times + end + + test "noon UTC lands on the 12Z bracket" do + times = Weather.sounding_times_around(~U[2026-04-20 12:00:00Z]) + assert ~U[2026-04-20 12:00:00Z] in times + end + end + + describe "hrrr_data_fully_present?/1" do + test "false when pos1 is missing" do + refute Weather.hrrr_data_fully_present?(%{pos1: nil}) + end + + test "false when qso_timestamp is missing" do + refute Weather.hrrr_data_fully_present?(%{qso_timestamp: nil, pos1: %{"lat" => 32.9, "lon" => -97.0}}) + end + end + + describe "existing_solar_dates/0" do + test "returns a MapSet of Dates for every row in solar_indices" do + date = ~D[2026-04-20] + + Weather.upsert_solar_indices_batch([ + %{ + date: date, + sfi: 120.0, + sfi_adjusted: 118.0, + sunspot_number: 55, + ap_index: 5, + kp_values: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + } + ]) + + set = Weather.existing_solar_dates() + assert MapSet.member?(set, date) + end + end +end diff --git a/test/microwaveprop/workers/gefs_fetch_worker_test.exs b/test/microwaveprop/workers/gefs_fetch_worker_test.exs index b09718d3..88424e4b 100644 --- a/test/microwaveprop/workers/gefs_fetch_worker_test.exs +++ b/test/microwaveprop/workers/gefs_fetch_worker_test.exs @@ -133,6 +133,62 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do 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] diff --git a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs index 526db62b..80242d79 100644 --- a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs +++ b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs @@ -67,5 +67,54 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do assert {:ok, %Oban.Job{args: _}} = Oban.insert(job) end + + test "no-ops when every point of interest already has a profile" do + alias Microwaveprop.Weather.HrrrNativeProfile + + create_contact(%{pos1: %{"lat" => 32.9, "lon" => -97.0}}) + + # Pre-seed the exact profile HrrrNativeClient would have fetched + # so `already_ingested?` returns true. + {:ok, _} = + %HrrrNativeProfile{} + |> HrrrNativeProfile.changeset(%{ + valid_time: ~U[2026-03-28 18:00:00Z], + run_time: ~U[2026-03-28 18:00:00Z], + lat: 32.9, + lon: -97.0, + level_count: 1, + heights_m: [10.0], + temp_k: [298.0], + spfh: [0.012], + pressure_pa: [101_000.0], + u_wind_ms: [2.0], + v_wind_ms: [1.0], + tke_m2s2: [0.5] + }) + |> Repo.insert() + + args = %{ + "year" => 2026, + "month" => 3, + "day" => 28, + "hour" => 18 + } + + assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) + end + + test "no-ops for hours without any contacts in the window" do + # Contact exists but outside the target hour's ±30m window. + create_contact(%{qso_timestamp: ~U[2026-03-28 15:00:00Z]}) + + args = %{ + "year" => 2026, + "month" => 3, + "day" => 28, + "hour" => 23 + } + + assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) + end end end diff --git a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs index 27616f29..33de2c79 100644 --- a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs +++ b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs @@ -45,5 +45,34 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do assert Ionosphere.latest_observation("MHJ45") == nil assert Ionosphere.latest_observation("AL945") end + + test "tolerates an HTTP transport failure with :ok + no rows inserted" do + Req.Test.stub(GiroClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert :ok = perform_job(IonosphereFetchWorker, %{}) + assert Repo.aggregate(Observation, :count, :id) == 0 + end + end + + describe "stations/0" do + test "exposes a non-empty list of CONUS ionosonde stations" do + stations = IonosphereFetchWorker.stations() + + assert is_list(stations) + assert length(stations) >= 2 + + # Every station entry has the shape the fetcher expects, with a + # plausible CONUS lat/lon envelope. + for s <- stations do + assert is_binary(s.code) + assert is_binary(s.name) + assert is_float(s.lat) or is_integer(s.lat) + assert is_float(s.lon) or is_integer(s.lon) + assert s.lat > 20.0 and s.lat < 75.0 + assert s.lon > -170.0 and s.lon < -60.0 + end + end end end diff --git a/test/microwaveprop_web/controllers/contact_map_controller_test.exs b/test/microwaveprop_web/controllers/contact_map_controller_test.exs new file mode 100644 index 00000000..f8410e05 --- /dev/null +++ b/test/microwaveprop_web/controllers/contact_map_controller_test.exs @@ -0,0 +1,60 @@ +defmodule MicrowavepropWeb.ContactMapControllerTest do + use MicrowavepropWeb.ConnCase, async: false + + alias Microwaveprop.Cache + + setup do + # The controller caches a gzipped payload under a module-level key — + # clear it between tests so gzip vs identity requests don't reuse + # each other's output. + Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload}) + :ok + end + + describe "GET /api/contacts/map" do + test "serves plain JSON when the client does not accept gzip", %{conn: conn} do + conn = get(conn, ~p"/api/contacts/map") + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert get_resp_header(conn, "content-encoding") == [] + assert [cache_control] = get_resp_header(conn, "cache-control") + assert cache_control =~ "max-age=60" + + # Body must be parseable JSON; on an empty dev/test DB that's a + # list (possibly empty) of contact tuples. + assert {:ok, parsed} = Jason.decode(conn.resp_body) + assert is_list(parsed) + end + + test "emits gzip-encoded body when the client advertises gzip support", %{conn: conn} do + conn = + conn + |> put_req_header("accept-encoding", "gzip, deflate") + |> get(~p"/api/contacts/map") + + assert conn.status == 200 + assert get_resp_header(conn, "content-encoding") == ["gzip"] + + # The gzipped body decompresses to valid JSON. + decompressed = :zlib.gunzip(conn.resp_body) + assert {:ok, _} = Jason.decode(decompressed) + end + + test "caches the gzipped payload across requests", %{conn: conn} do + conn1 = + conn + |> put_req_header("accept-encoding", "gzip") + |> get(~p"/api/contacts/map") + + conn2 = + build_conn() + |> put_req_header("accept-encoding", "gzip") + |> get(~p"/api/contacts/map") + + # Byte-identical bodies prove the second hit came from the cache, + # not a fresh Radio.contact_map_payload/0 + gzip round-trip. + assert conn1.resp_body == conn2.resp_body + end + end +end diff --git a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs index 040114ef..a5346823 100644 --- a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs +++ b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs @@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do alias Microwaveprop.Accounts alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Repo defp admin_fixture do @@ -71,4 +72,139 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do assert html =~ "0 pending" end end + + describe "review flow" do + # Walks a pending edit through review → approve (and review → reject) + # via LiveView events. Each path hits one of the main approve/reject + # branches in the LiveView plus the helper label/value renderers. + + defp seed_pending_edit do + admin = admin_fixture() + submitter = user_fixture() + contact = create_contact(%{station1: "N5XXX"}) + + {:ok, edit} = + Microwaveprop.Radio.create_contact_edit(contact, submitter, %{ + "station1" => "N5YYY", + "mode" => "SSB", + "band" => "432", + "grid1" => "EM13", + "qso_timestamp" => "2026-04-01 12:00:00Z" + }) + + {admin, contact, edit} + end + + test "clicking Review opens the edit inspector with current + proposed values", %{conn: conn} do + {admin, contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + html = + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + # Inspector panel shows labels for every field that changed. + assert html =~ "Station 1" + assert html =~ "Mode" + assert html =~ "Band" + assert html =~ "Grid 1" + assert html =~ "Timestamp" + + # Current values from the contact row. + assert html =~ contact.station1 + # Proposed values from the edit. + assert html =~ "N5YYY" + assert html =~ "SSB" + end + + test "approve applies the edit and clears the inspector", %{conn: conn} do + {admin, _contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + html = + lv + |> element(~s|button[phx-click="approve"]|) + |> render_click() + + # Flash + pending counter refresh. + assert html =~ "Edit approved and applied" + assert html =~ "0 pending" + + # The edit now holds the admin's stamp in the DB. + approved = Repo.get!(ContactEdit, edit.id) + assert approved.status == :approved + assert approved.reviewed_by_id == admin.id + end + + test "reject leaves the contact unchanged and records the rejection", %{conn: conn} do + {admin, contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + # Type an admin note into the review textarea first. It's not + # wrapped in a