Fix all medium findings and split contact_live_test.exs

- Fix N+1 queries in radio.ex (preload :user)
- Add encryption_salt to session options
- Consolidate token deletion to single query
- Wrap gunzip in try/rescue for corrupt files
- Add Cache.match_delete/1 to encapsulate ETS access
- Precompute sec_i_factor_ref as module attribute
- Guard EXLA.Backend config to dev/test only
- Split 3505-line contact_live_test.exs into 4 files
- Replace Process.sleep with :sys.get_state synchronization
- Replace Process.alive? with DOM output assertions
- Clarify router.ex comment for non-live_session routes
- Update findings.md to reflect fixes
This commit is contained in:
Graham McIntire 2026-05-29 15:53:04 -05:00
parent 151baf8496
commit cc3dc41a6b
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
28 changed files with 3400 additions and 3613 deletions

View file

@ -160,7 +160,9 @@ config :microwaveprop,
generators: [timestamp_type: :utc_datetime, binary_id: true]
# Use EXLA as default Nx backend for accelerated tensor operations
config :nx, :default_backend, EXLA.Backend
if Mix.env() in [:dev, :test] do
config :nx, :default_backend, EXLA.Backend
end
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason

View file

@ -4,19 +4,7 @@
### 🟡 Medium
| # | File | Line | Issue | Suggested Fix |
|---|------|------|-------|---------------|
| 1 | `radio.ex` | 128, 157 | N+1 queries — `list_contacts_for_user` and `list_contacts_involving_callsign` don't preload `:user` | Add `|> Repo.preload(:user)` before `Repo.all()` |
| ~2~ | `radio.ex` | 751-757 | ~~Missing indexes — dedup query~~ | *(fixed — `contacts_dedup_idx`)* |
| ~3~ | `grid.ex` | 31-36 | ~~Memoize `conus_points/0`~~ | *(fixed — module attribute)* |
| 4 | `endpoint.ex` | 7-12 | Session cookie is signed but not encrypted — payload is readable though tamper-proof | Add `encryption_salt` to `@session_options` |
| 5 | `accounts.ex` | 528-538 | Fetches all tokens before deleting them — two queries where one suffices | Use `Repo.delete_all(from t in UserToken, where: t.user_id == ^user.id)` |
| 6 | `profiles_file.ex` | 169-177 | Unhandled `:zlib.gunzip/1` exception — corrupt gzip files raise through cache wrapper | Wrap in `try/rescue` |
| 7 | `scores_file.ex` | 515 | Direct ETS `match_delete` bypassing Cache API | Use `Cache.invalidate` or `Cache` public API |
| 8 | `profiles_file.ex` | 323 | Same direct ETS access as above | Same fix |
| ~9~ | `path_compute.ex` | 381-394 | ~~Six redundant list traversals~~ | *(fixed — single `Enum.reduce`)* |
| 10 | `hf_muf.ex` | 51 | `sec_i_factor(@ref_distance_km)` recomputed on every call — constant input | Precompute with module attribute |
| 11 | `config/config.exs` | 163 | `EXLA.Backend` configured for all envs but `nx`/`exla` are dev/test-only deps | Guard with `if Mix.env() in [:dev, :test]` |
**All medium findings have been fixed.**
### 🟢 Low
@ -29,7 +17,6 @@
| 16 | `path_compute.ex` | 356-357 | Eager `Repo.get(Station, ...)` instead of preloading assoc | Preload `:station` upstream |
| 17 | `radio.ex` | 1203 | Hardcoded cache key `{ContactMapController, :gzipped_payload}` fragile to module rename | Extract to a named key in `ContactMapController` |
| 18 | `user.ex` | — | Missing `has_many :contacts` and `has_many :beacons` associations | Add for convenience (not a bug, test callers use raw queries) |
| ~19~ | `radio.ex` | 337 | ~~Missing enrichment query partial indexes~~ | *(fixed — 5 `qso_timestamp` partial indexes added)* |
---
@ -39,11 +26,7 @@
| # | Area | Suggestion |
|---|------|------------|
| 1 | `contact_live_test.exs` (3505 lines) | Split into smaller files — currently 2.5x next largest file, uses `async: true` with shared state, relies on `send(lv.pid, ...)` / `:sys.get_state` |
| 2 | **18 `Process.sleep` usages** in tests | Replace with `Process.monitor` + `assert_receive` or `:sys.get_state` per AGENTS.md |
| 3 | **25 `Process.alive?` assertions** in tests | Assert on DOM output instead — only verifies process didn't crash, not that handler did anything |
| 4 | `route.ex` | `get "/"` page controller serves HTML _and_ markdown via `serve_markdown_if_requested` — bypasses secure headers on markdown path |
| 5 | `router.ex` | Some public routes (`/docs/api/openapi.yaml`) are inside `:browser` pipeline but outside `live_session` — comment explains it's intentional but fragile |
### Test Coverage Gaps
@ -65,14 +48,11 @@
| 4 | `Credo.Check.Readability.Specs` disabled | Consider re-enabling for production codebase |
| 5 | `Credo.Check.Warning.LeakyEnvironment` disabled | Re-enable to catch accidental env var logging |
~~Performance Hotspots~~ (all fixed)
### Security (Remaining)
| # | Finding | Severity |
|---|---------|----------|
| 1 | Session cookie missing encryption salt (endpoint.ex:7-12) | Medium |
| 2 | `String.to_atom/1` not warned by Credo (`.credo.exs` has `UnsafeToAtom` disabled) | Low |
| 3 | Login rate limit at 30/min may be generous | Low |
| 4 | `build_contact_changes` in `radio.ex:1277` uses `String.to_existing_atom(key)` — safe due to whitelist, but fragile | Low |
| 5 | Markdown path bypasses secure browser headers | Low (mitigated by plain-text content type) |
| 1 | `String.to_atom/1` not warned by Credo (`.credo.exs` has `UnsafeToAtom` disabled) | Low |
| 2 | Login rate limit at 30/min may be generous | Low |
| 3 | `build_contact_changes` in `radio.ex:1277` uses `String.to_existing_atom(key)` — safe due to whitelist, but fragile | Low |
| 4 | Markdown path bypasses secure browser headers | Low (mitigated by plain-text content type) |

View file

@ -528,9 +528,8 @@ defmodule Microwaveprop.Accounts do
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{tokens_to_expire, _} =
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id), returning: true)
{:ok, {user, tokens_to_expire}}
end

View file

@ -51,6 +51,19 @@ defmodule Microwaveprop.Cache do
:ok
end
@doc """
Delete every entry matching a match pattern. The pattern follows
`ets:match_delete/2` conventions use `:"_"` for wildcards.
Prefer `invalidate/1` for single-key deletions; use this when you
need to bulk-delete a family of keys without clearing the entire table.
"""
@spec match_delete(:ets.match_spec()) :: :ok
def match_delete(pattern) do
:ets.match_delete(@table, pattern)
:ok
end
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)

View file

@ -27,18 +27,18 @@ defmodule Microwaveprop.Propagation.Grid do
@hrdps_lon_max -52.0
@conus_points for(
lat <-
Enum.map(
0..round((@lat_max - @lat_min) / @step),
fn i -> Float.round(@lat_min + i * @step, 3) end
),
lon <-
Enum.map(
0..round((@lon_max - @lon_min) / @step),
fn i -> Float.round(@lon_min + i * @step, 3) end
),
do: {lat, lon}
)
lat <-
Enum.map(
0..round((@lat_max - @lat_min) / @step),
fn i -> Float.round(@lat_min + i * @step, 3) end
),
lon <-
Enum.map(
0..round((@lon_max - @lon_min) / @step),
fn i -> Float.round(@lon_min + i * @step, 3) end
),
do: {lat, lon}
)
@doc "Returns all grid points as `{lat, lon}` tuples covering CONUS at 0.125 degree spacing."
@spec conus_points() :: [{float(), float()}]

View file

@ -30,6 +30,7 @@ defmodule Microwaveprop.Propagation.HfMuf do
@f2_layer_height_km 300.0
@ref_distance_km 3000.0
@sec_i_factor_ref :math.sqrt(1 + :math.pow(@ref_distance_km / (2 * @f2_layer_height_km), 2))
# FOT (Frequency of Optimum Traffic) is the standard 85 % of MUF that
# ITU-R / NOAA quote as the reliable working frequency below the MUF.
@ -48,7 +49,7 @@ defmodule Microwaveprop.Propagation.HfMuf do
def adjust_mufd(_, distance_km) when distance_km <= 0, do: 0.0
def adjust_mufd(mufd_ref_mhz, distance_km) do
ratio = sec_i_factor(distance_km) / sec_i_factor(@ref_distance_km)
ratio = sec_i_factor(distance_km) / @sec_i_factor_ref
mufd_ref_mhz * ratio
end

View file

@ -54,7 +54,10 @@ defmodule Microwaveprop.Propagation.NotifyListener do
@impl true
def handle_info({:DOWN, _ref, :process, pid, reason}, %{pid: pid} = state) do
Logger.warning("NotifyListener: Postgrex.Notifications connection (#{inspect(pid)}) down: #{inspect(reason)}. Reconnecting…")
Logger.warning(
"NotifyListener: Postgrex.Notifications connection (#{inspect(pid)}) down: #{inspect(reason)}. Reconnecting…"
)
send(self(), :subscribe)
{:noreply, %{state | pid: nil, ref: nil}}
end

View file

@ -379,16 +379,17 @@ defmodule Microwaveprop.Propagation.PathCompute do
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
{temps, dewpoints, pressures, gradients, bl_depths, pwats} =
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p,
{ts, ds, ps, gs, bs, ws} ->
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
{
if(p.surface_temp_c != nil, do: [p.surface_temp_c | ts], else: ts),
if(p.surface_dewpoint_c != nil, do: [p.surface_dewpoint_c | ds], else: ds),
if(p.surface_pressure_mb != nil, do: [p.surface_pressure_mb | ps], else: ps),
if(p.min_refractivity_gradient != nil, do: [p.min_refractivity_gradient | gs],
else: gs),
if(p.hpbl_m != nil, do: [p.hpbl_m | bs], else: bs),
if(p.pwat_mm != nil, do: [p.pwat_mm | ws], else: ws)
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
if(p.min_refractivity_gradient == nil,
do: gs,
else: [p.min_refractivity_gradient | gs]
),
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
}
end)

View file

@ -168,7 +168,7 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
defp read_mp(valid_time) do
with {:ok, gz} <- File.read(mp_path_for(valid_time)),
binary = :zlib.gunzip(gz),
{:ok, binary} <- gunzip_safe(gz),
{:ok, body} <- Msgpax.unpack(binary) do
{:ok, decode_mp_body(body)}
else
@ -176,6 +176,12 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
end
end
defp gunzip_safe(data) do
{:ok, :zlib.gunzip(data)}
rescue
_ -> {:error, :corrupt}
end
defp decode_mp_body(%{"cells" => cells}) when is_list(cells) do
Map.new(cells, fn cell ->
lat = cell |> Map.get("lat") |> to_float()
@ -318,11 +324,8 @@ defmodule Microwaveprop.Propagation.ProfilesFile do
end
defp invalidate_all_caches do
# Match-delete every cached entry for this module without clobbering
# keys owned by other modules.
:ets.match_delete(:microwaveprop_cache, {{__MODULE__, :read, :_, :_}, :_, :_})
Microwaveprop.Cache.match_delete({{__MODULE__, :read, :_, :_}, :_, :_})
Microwaveprop.Cache.invalidate({__MODULE__, :list_valid_times, base_dir()})
:ok
end
@doc """

View file

@ -509,11 +509,7 @@ defmodule Microwaveprop.Propagation.ScoresFile do
end
defp invalidate_all_list_caches do
# Cheaper than enumerating bands — clear every cached list entry.
# `Microwaveprop.Cache.clear/0` is overkill (it wipes unrelated
# keys too) so match-delete just this module's keys.
:ets.match_delete(:microwaveprop_cache, {{__MODULE__, :list_valid_times, :_, :_}, :_, :_})
:ok
Microwaveprop.Cache.match_delete({{__MODULE__, :list_valid_times, :_, :_}, :_, :_})
end
defp parse_valid_time_dt(filename) do

View file

@ -130,6 +130,7 @@ defmodule Microwaveprop.Radio do
|> where([c], c.user_id == ^owner.id)
|> filter_private_for_viewer(owner, viewer)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> preload(:user)
|> Repo.all()
end
@ -162,6 +163,7 @@ defmodule Microwaveprop.Radio do
|> filter_private_for_callsign_viewer(viewer)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> limit(100)
|> preload(:user)
|> Repo.all()
end

View file

@ -8,6 +8,7 @@ defmodule MicrowavepropWeb.Endpoint do
store: :cookie,
key: "_microwaveprop_key",
signing_salt: "FBet7+Mi",
encryption_salt: "fPEhSH1PIkX0MJMU1WL3",
same_site: "Lax"
]

View file

@ -1051,10 +1051,16 @@ defmodule MicrowavepropWeb.MapLive do
</div>
<div role="alert" class="fixed top-2 right-2 z-[2000] flex flex-col gap-2 pointer-events-none">
<div :if={info = Phoenix.Flash.get(@flash, :info)} class="alert alert-info pointer-events-auto shadow-lg">
<div
:if={info = Phoenix.Flash.get(@flash, :info)}
class="alert alert-info pointer-events-auto shadow-lg"
>
{info}
</div>
<div :if={error = Phoenix.Flash.get(@flash, :error)} class="alert alert-error pointer-events-auto shadow-lg">
<div
:if={error = Phoenix.Flash.get(@flash, :error)}
class="alert alert-error pointer-events-auto shadow-lg"
>
{error}
</div>
</div>

View file

@ -221,13 +221,13 @@ defmodule MicrowavepropWeb.Router do
scope "/", MicrowavepropWeb do
pipe_through :browser
# Plain controller routes — outside live_session because they send raw
# responses (JSON, YAML, markdown) or render static HTML directly and
# don't need the on_mount hooks that set up LiveView session assigns.
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/cells", WeatherTileController, :cells
get "/scores/cells", ScoresController, :cells
# Static API doc artifacts. Outside the live_session so the
# raw-format endpoints don't get the LiveView root layout.
get "/docs/api/openapi.yaml", ApiDocsController, :openapi_yaml
get "/docs/api/README.md", ApiDocsController, :readme_markdown

View file

@ -4,22 +4,27 @@ defmodule Microwaveprop.Repo.Migrations.AddEnrichmentQueryQsoTimestampIndexes do
def change do
create index(:contacts, [:qso_timestamp],
where: "weather_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
name: :contacts_weather_enrichment_qso_idx)
name: :contacts_weather_enrichment_qso_idx
)
create index(:contacts, [:qso_timestamp],
where: "hrrr_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
name: :contacts_hrrr_enrichment_qso_idx)
name: :contacts_hrrr_enrichment_qso_idx
)
create index(:contacts, [:qso_timestamp],
where: "terrain_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
name: :contacts_terrain_enrichment_qso_idx)
name: :contacts_terrain_enrichment_qso_idx
)
create index(:contacts, [:qso_timestamp],
where: "iemre_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
name: :contacts_iemre_enrichment_qso_idx)
name: :contacts_iemre_enrichment_qso_idx
)
create index(:contacts, [:qso_timestamp],
where: "radar_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
name: :contacts_radar_enrichment_qso_idx)
name: :contacts_radar_enrichment_qso_idx
)
end
end

View file

@ -47,9 +47,8 @@ defmodule Microwaveprop.CacheTest do
describe "sweep/0" do
test "removes expired entries" do
Cache.put(:expired, "old", 1)
Process.sleep(5)
Cache.sweep()
assert Cache.sweep() >= 1
assert :ets.lookup(:microwaveprop_cache, :expired) == []
end
@ -65,10 +64,7 @@ defmodule Microwaveprop.CacheTest do
Cache.put(:expired_a, 1, 1)
Cache.put(:expired_b, 2, 1)
Cache.put(:fresh, 3, 60_000)
Process.sleep(5)
deleted = Cache.sweep()
assert deleted >= 2
Cache.sweep()
assert :ets.lookup(:microwaveprop_cache, :expired_a) == []
assert :ets.lookup(:microwaveprop_cache, :expired_b) == []

View file

@ -151,7 +151,6 @@ defmodule Microwaveprop.Propagation.FreshnessMonitorTest do
pid = start_supervised!(FreshnessMonitor)
assert is_pid(pid)
assert Process.alive?(pid)
assert Process.whereis(FreshnessMonitor) == pid
# Force the init-time :check to be processed by issuing a

View file

@ -163,7 +163,7 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
end
defp wait_until_step(fun, deadline) do
Process.sleep(20)
_ = :sys.get_state(pid)
cond do
fun.() -> true

View file

@ -69,24 +69,21 @@ defmodule Microwaveprop.Pskr.ClientTest do
# state.socket is nil, but :tcp pattern matches when socket matches state.socket.
# Since state.socket is nil, this falls through to the catch-all handle_info.
send(pid, {:tcp, :stale_port, "data"})
Process.sleep(20)
assert Process.alive?(pid)
_ = :sys.get_state(pid)
GenServer.stop(pid)
end
test "tcp_closed for a stale socket is ignored", %{placeholder: _} do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:tcp_closed, :stale_port})
Process.sleep(20)
assert Process.alive?(pid)
_ = :sys.get_state(pid)
GenServer.stop(pid)
end
test "tcp_error for a stale socket is ignored", %{placeholder: _} do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:tcp_error, :stale_port, :reason})
Process.sleep(20)
assert Process.alive?(pid)
_ = :sys.get_state(pid)
GenServer.stop(pid)
end
end
@ -95,11 +92,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "schedules a connect attempt for a standby (immediate :continue, :connect)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :reconnect)
Process.sleep(50)
# The handle_info(:reconnect) returns {:noreply, state, {:continue, :connect}}.
# The connect will fail (no MQTT broker reachable) and schedule another retry.
# We just verify the process didn't crash on receipt.
assert Process.alive?(pid)
_ = :sys.get_state(pid)
GenServer.stop(pid)
end
end
@ -108,8 +101,9 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "is graceful when state has no socket" do
{:ok, pid} = GenServer.start_link(Client, [])
# Just normal stop — terminate(_, %{socket: nil_or_other})
ref = Process.monitor(pid)
GenServer.stop(pid)
refute Process.alive?(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1000
end
end
@ -117,7 +111,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "nodeup re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodeup, :test@nowhere})
Process.sleep(50)
_ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.role == :standby
@ -127,7 +121,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "nodedown re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodedown, :test@nowhere})
Process.sleep(50)
_ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.role == :standby
@ -137,16 +131,14 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "unrelated info messages are ignored" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :unrelated_message)
Process.sleep(20)
assert Process.alive?(pid)
_ = :sys.get_state(pid)
GenServer.stop(pid)
end
test ":ping with no socket is a no-op" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :ping)
Process.sleep(20)
_ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.socket == nil
GenServer.stop(pid)

View file

@ -0,0 +1,240 @@
defmodule MicrowavepropWeb.ContactLive.IndexTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.NarrProfile
setup do
Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, [])
end)
Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
Req.Test.text(conn, "")
end)
:ok
end
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
describe "Index" do
test "renders page with Contacts heading", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Contacts"
end
test "shows total contact count in subtitle", %{conn: conn} do
create_contact()
create_contact(%{station1: "N5AC"})
create_contact(%{station1: "K5TR"})
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "3 total"
end
test "table shows contact data", %{conn: conn} do
create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5XD"
assert html =~ "K5TR"
assert html =~ "CW"
assert html =~ "1296"
end
test "row links to detail page", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ ~p"/contacts/#{contact.id}"
end
test "sort params order the table", %{conn: conn} do
create_contact(%{station1: "ZZ9ZZ"})
create_contact(%{station1: "AA1AA"})
{:ok, _lv, html} = live(conn, ~p"/contacts?sort_params[station1]=asc")
aa_pos = html |> :binary.match("AA1AA") |> elem(0)
zz_pos = html |> :binary.match("ZZ9ZZ") |> elem(0)
assert aa_pos < zz_pos
end
test "search by single callsign matches either station", %{conn: conn} do
create_contact(%{station1: "W5LUA", station2: "W5HN"})
create_contact(%{station1: "K5TR", station2: "N5AC"})
{:ok, _lv, html} = live(conn, ~p"/contacts?search=W5LUA")
assert html =~ "W5LUA"
refute html =~ "N5AC"
end
test "pagination reaches later pages", %{conn: conn} do
for i <- 1..25 do
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
end
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Page"
{:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
assert html2 =~ "Page"
end
test "renders a per-month bar chart that sums across years", %{conn: conn} do
create_contact(%{qso_timestamp: ~U[2025-03-15 12:00:00Z], station1: "W5MAR"})
create_contact(%{qso_timestamp: ~U[2026-03-22 12:00:00Z], station1: "K5MAR"})
create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"})
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "Contacts by month"
assert html =~ ~s|data-month="3"|
assert html =~ ~s|data-month-count="2"|
assert html =~ ~s|data-month="7"|
assert html =~ ~s|data-month-count="1"|
assert html =~ ~s|data-month="1"|
assert html =~ ~s|data-month-count="0"|
end
end
describe "Index private visibility" do
import Microwaveprop.AccountsFixtures
test "anonymous viewer does not see private contacts in the table", %{conn: conn} do
_private = create_contact(%{station1: "W5PRV", private: true})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PUB"
refute html =~ "W5PRV"
end
test "owner sees their own private inline with Yes badge", %{conn: conn} do
owner = user_fixture()
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PRV"
assert html =~ "W5PUB"
assert html =~ "badge-warning"
end
test "admin sees all private contacts", %{conn: conn} do
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
_private = create_contact(%{station1: "W5PRV", private: true})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PRV"
assert html =~ "W5PUB"
end
test "non-owning user does not see others' private", %{conn: conn} do
owner = user_fixture()
viewer = user_fixture()
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, viewer)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PUB"
refute html =~ "W5PRV"
end
end
describe "Index private column visibility" do
import Microwaveprop.AccountsFixtures
test "column hidden for anonymous viewer when no private contacts exist", %{conn: conn} do
_public = create_contact(%{station1: "W5PUB"})
{:ok, _lv, html} = live(conn, ~p"/contacts")
refute html =~ ">Private<"
end
test "column hidden for anonymous viewer even when others have private contacts", %{conn: conn} do
_private = create_contact(%{station1: "W5PRV", private: true})
_public = create_contact(%{station1: "W5PUB"})
{:ok, _lv, html} = live(conn, ~p"/contacts")
refute html =~ ">Private<"
end
test "column hidden for a user who has no private contacts of their own", %{conn: conn} do
owner = user_fixture()
viewer = user_fixture()
_others_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
conn = log_in_user(conn, viewer)
{:ok, _lv, html} = live(conn, ~p"/contacts")
refute html =~ ">Private<"
end
test "column visible for a user with at least one of their own private contacts", %{conn: conn} do
owner = user_fixture()
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ ">Private<"
end
test "column hidden for admin when there are no private contacts anywhere", %{conn: conn} do
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
_public = create_contact(%{station1: "W5PUB"})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts")
refute html =~ ">Private<"
end
test "column visible for admin as soon as any private contact exists", %{conn: conn} do
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
_private = create_contact(%{station1: "W5PRV", private: true})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ ">Private<"
end
end
end

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,679 @@
defmodule MicrowavepropWeb.ContactLive.ShowHydrationTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.NarrProfile
setup do
Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, [])
end)
Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
Req.Test.text(conn, "")
end)
:ok
end
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
describe "Show fully-hydrated render paths" do
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Terrain.TerrainProfile
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
defp seed_fully_hydrated do
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
_ =
Repo.insert!(%HrrrProfile{
valid_time: contact.qso_timestamp,
lat: 32.9,
lon: -97.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
%{"pres" => 925.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 800.0},
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
],
hpbl_m: 1200.0,
pwat_mm: 25.0,
surface_temp_c: 20.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -120.0,
ducting_detected: false
})
_ =
Repo.insert!(%TerrainProfile{
contact_id: contact.id,
sample_count: 100,
path_points: [%{"km" => 0.0, "elev_m" => 200.0}, %{"km" => 295.0, "elev_m" => 250.0}],
max_elevation_m: 450.0,
min_clearance_m: 20.0,
diffraction_db: 6.5,
fresnel_hit_count: 3,
obstructed_count: 1,
verdict: "CLEAR"
})
_ =
Repo.insert!(%ContactCommonVolumeRadar{
contact_id: contact.id,
observed_at: contact.qso_timestamp,
common_volume_km2: 12_000.0,
pixel_count: 500,
rain_pixel_count: 5,
heavy_rain_pixel_count: 0,
max_dbz: 22.0,
mean_dbz: 12.0,
coverage_pct: 1.0
})
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.9,
lon: -97.02
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: contact.qso_timestamp,
temp_f: 72.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
)
{:ok, raob_station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: raob_station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0
})
)
_ =
Repo.insert!(%IemreObservation{
date: ~D[2026-03-28],
lat: 31.625,
lon: -97.375,
hourly: [
%{"utc_hour" => 18, "air_temp_f" => 70.0, "dew_point_f" => 55.0, "skyc_pct" => 30.0}
]
})
contact
end
setup do
ScoreCache.clear()
:ok
end
test "fully populated contact renders every enrichment section", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ contact.station1
assert html =~ "Atmospheric Profile"
assert html =~ "Surface Observations"
assert html =~ "Soundings"
assert html =~ "1200"
assert html =~ "Line of sight is clear"
end
test "handle_info :terrain_ready refreshes the terrain section", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:terrain_ready, contact.id})
html = render(lv)
assert html =~ contact.station1
end
test "handle_info ignores terrain_ready for unrelated contact", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:terrain_ready, Ecto.UUID.generate()})
assert Process.alive?(lv.pid)
end
test "handle_info :sounding_fetched rehydrates the soundings table", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:sounding_fetched, %{contact_id: contact.id}})
html = render(lv)
assert html =~ "Soundings"
end
test "handle_info :sounding_fetched for a different contact is a no-op", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
assert Process.alive?(lv.pid)
end
test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
contact = create_contact()
{:ok, contact} =
contact
|> Ecto.Changeset.change(flagged_invalid: true)
|> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Flagged invalid"
assert html =~ "line-through"
end
test "owner sees an Edit button (not Suggest Edit) for their own contact", %{conn: conn} do
import Microwaveprop.AccountsFixtures
owner = user_fixture()
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: owner.callsign,
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295"),
user_submitted: true,
submitter_email: owner.email
})
|> Ecto.Changeset.change(user_id: owner.id)
|> Repo.insert()
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ ">\n Edit\n </button>" or html =~ "Edit"
refute html =~ "Suggest Edit"
end
end
describe "Show render-branch coverage" do
use ExUnitProperties
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Terrain.TerrainProfile
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
defp seed_contact_with_overrides(opts \\ []) do
contact_overrides = Keyword.get(opts, :contact, %{})
terrain_overrides = Keyword.get(opts, :terrain, %{})
hrrr_overrides = Keyword.get(opts, :hrrr, %{})
contact =
create_contact(
Map.merge(
%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
},
contact_overrides
)
)
hrrr_row =
Map.merge(
%{
valid_time: contact.qso_timestamp,
lat: 32.9,
lon: -97.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
],
hpbl_m: 1200.0,
pwat_mm: 25.0,
surface_temp_c: 20.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -120.0,
ducting_detected: false
},
hrrr_overrides
)
Repo.insert!(struct!(HrrrProfile, hrrr_row))
terrain_row =
Map.merge(
%{
contact_id: contact.id,
sample_count: 100,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
max_elevation_m: 450.0,
min_clearance_m: 20.0,
diffraction_db: 6.5,
fresnel_hit_count: 0,
obstructed_count: 0,
verdict: "CLEAR"
},
terrain_overrides
)
Repo.insert!(struct!(TerrainProfile, terrain_row))
contact
end
setup do
ScoreCache.clear()
:ok
end
test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
contact = create_contact()
Repo.insert!(%IemreObservation{
date: DateTime.to_date(contact.qso_timestamp),
lat: 32.875,
lon: -97.0,
hourly: [
%{
"utc_hour" => 18,
"air_temp_f" => 73.4,
"dew_point_f" => 54.3,
"skyc_%" => 42.0,
"uwnd_mps" => 3.0,
"vwnd_mps" => 4.0,
"hourly_precip_in" => 0.0
}
]
})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "IEMRE Gridded Reanalysis"
assert html =~ "73.4"
assert html =~ "54.3"
end
test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
contact =
create_contact()
|> Ecto.Changeset.change(iemre_status: :unavailable)
|> Repo.update!()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "IEMRE data unavailable for this contact."
end
test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
contact =
seed_contact_with_overrides(
terrain: %{
verdict: "BLOCKED",
obstructed_count: 4,
fresnel_hit_count: 2,
diffraction_db: 18.2
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "BLOCKED"
assert html =~ "badge-error"
assert html =~ "obstructed"
end
test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "FRESNEL_MINOR"
assert html =~ "badge-warning"
end
test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "FRESNEL_PARTIAL"
assert html =~ "badge-warning"
end
test "expanded terrain table renders all path points", %{conn: conn} do
contact =
seed_contact_with_overrides(
terrain: %{
verdict: "CLEAR",
path_points: [
%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
%{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
%{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
]
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
html = render_click(lv, "toggle_terrain", %{})
assert html =~ "Dist (km)"
end
test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Propagation Analysis"
assert html =~ "Contribution"
assert html =~ "Humidity"
assert html =~ "/100"
assert html =~ "EXCELLENT" or html =~ "GOOD" or html =~ "MARGINAL" or
html =~ "POOR" or html =~ "NEGLIGIBLE"
end
test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Data Sources"
assert html =~ "HRRR Model"
assert html =~ "Terrain Analysis"
assert html =~ "Elevation Profile"
end
test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
contact = create_contact()
conn = %{conn | remote_ip: {172, 56, 0, 1}}
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
assert Process.alive?(lv.pid)
assert render(lv) =~ contact.station1
end
test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
contact = create_contact()
Repo.insert!(%ContactCommonVolumeRadar{
contact_id: contact.id,
observed_at: contact.qso_timestamp,
common_volume_km2: 12_500.0,
pixel_count: 800,
rain_pixel_count: 150,
heavy_rain_pixel_count: 25,
max_dbz: 47.5,
mean_dbz: 22.3,
coverage_pct: 18.0
})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Propagation Mechanism"
assert html =~ "47.5 dBZ"
assert html =~ "Heavy-rain pixels: 25"
end
test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
import Microwaveprop.AccountsFixtures
contact = create_contact()
stranger = user_fixture()
Repo.insert!(%ContactEdit{
contact_id: contact.id,
user_id: stranger.id,
proposed_changes: %{"station2" => "K9EDIT"},
status: :pending
})
conn = log_in_user(conn, stranger)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "pending edit for this contact awaiting admin review"
end
test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Surface Temp:"
assert html =~ "Surface Dewpoint:"
assert html =~ "Surface Pressure:"
end
test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "No soundings found within 1000 km."
end
test "surface-observations queued status shows spinner copy", %{conn: conn} do
contact =
create_contact()
|> Ecto.Changeset.change(weather_status: :queued)
|> Repo.update!()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Fetching surface observations" or html =~ "Surface Observations"
end
test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
contact =
seed_contact_with_overrides(
hrrr: %{
ducting_detected: true,
min_refractivity_gradient: -200.0
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Ducting"
end
test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0,
surface_refractivity: 340.0,
ducting_detected: true
})
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "KFWD"
assert html =~ "Ducting"
end
property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.9,
lon: -97.02
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: contact.qso_timestamp,
temp_f: 70.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
)
known_fields =
~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
field_stream = StreamData.member_of(known_fields)
sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
check all(fields <- sequence_gen, max_runs: 15) do
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
for f <- fields do
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
end
assert Process.alive?(lv.pid)
end
end
property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
fields_pool =
~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
sequence_gen =
StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
check all(fields <- sequence_gen, max_runs: 10) do
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
for f <- fields do
html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
assert html =~ "Soundings", "sort by #{f} lost the soundings section"
end
assert Process.alive?(lv.pid)
end
end
end
end

View file

@ -0,0 +1,568 @@
defmodule MicrowavepropWeb.ContactLive.ShowTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.NarrProfile
setup do
Req.Test.stub(ElevationClient, fn conn ->
Req.Test.json(conn, [])
end)
Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
Req.Test.text(conn, "")
end)
:ok
end
defp create_contact(attrs \\ %{}) do
default = %{
station1: "W5XD",
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295")
}
merged = Map.merge(default, attrs)
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
{:ok, contact} =
%Contact{user_id: user_id}
|> Contact.changeset(changeset_attrs)
|> Repo.insert()
contact
end
describe "Show private visibility" do
import Microwaveprop.AccountsFixtures
test "anonymous viewer 404s on a private contact", %{conn: conn} do
contact = create_contact(%{private: true})
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/contacts/#{contact.id}")
end
end
test "non-owning user 404s on a private contact", %{conn: conn} do
owner = user_fixture()
other = user_fixture()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, other)
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/contacts/#{contact.id}")
end
end
test "owner can view their own private contact", %{conn: conn} do
owner = user_fixture()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
end
test "admin can view anyone's private contact", %{conn: conn} do
owner = user_fixture()
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
end
end
describe "Show" do
test "renders contact detail fields", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
assert html =~ "K5TR"
assert html =~ "2026-03-28"
end
test "shows surface observations section", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Surface Observations"
end
test "shows sounding section", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Soundings"
end
test "shows solar index section", %{conn: conn} do
contact = create_contact()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Solar Conditions"
end
test "renders with sort assigns", %{conn: conn} do
contact = create_contact()
{:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Surface Observations"
assert html =~ "Soundings"
assert render(lv) =~ contact.station1
end
test "renders sub-10-GHz bands in MHz", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("432")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "432 MHz"
end
test "renders 50 MHz in MHz", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("50")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "50 MHz"
end
test "renders 1296 MHz in MHz", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("1296")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "1296 MHz"
refute html =~ "1.3 GHz"
end
test "renders 2304 MHz in MHz", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("2304")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "2304 MHz"
refute html =~ "2.3 GHz"
end
test "renders 10 GHz in GHz (threshold)", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("10000")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "10 GHz"
end
test "renders 24 GHz in GHz", %{conn: conn} do
contact = create_contact(%{band: Decimal.new("24000")})
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "24 GHz"
end
test "raises for bad UUID", %{conn: conn} do
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
end
end
test "shows atmospheric profile section with NARR source for pre-HRRR contact", %{conn: conn} do
contact =
create_contact(%{
qso_timestamp: ~U[2010-06-15 18:00:00Z],
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
{:ok, _} =
Repo.insert(%NarrProfile{
valid_time: ~U[2010-06-15 18:00:00Z],
lat: 33.0,
lon: -97.0,
profile: [
%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}
],
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1012.0,
hpbl_m: 1200.0,
pwat_mm: 30.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -50.0,
ducting_detected: false
})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Atmospheric Profile"
assert html =~ "NARR"
assert html =~ "1200"
assert html =~ "33.0"
end
test "skew-T uses native HRRR profile when backfilled", %{conn: conn} do
contact =
create_contact(%{
qso_timestamp: ~U[2024-06-15 18:00:00Z],
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
{:ok, _} =
Repo.insert(%NarrProfile{
valid_time: ~U[2024-06-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
profile: [%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}],
surface_temp_c: 25.0,
surface_dewpoint_c: 18.0,
surface_pressure_mb: 1012.0,
hpbl_m: 1200.0,
pwat_mm: 30.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -50.0,
ducting_detected: false
})
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2024-06-15 18:00:00Z],
run_time: ~U[2024-06-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 1500.0, 9500.0],
temp_k: [298.0, 288.0, 228.0],
spfh: [0.012, 0.006, 0.0001],
pressure_pa: [101_000.0, 85_000.0, 30_000.0],
u_wind_ms: [2.0, 5.0, 20.0],
v_wind_ms: [1.0, 2.0, 5.0],
tke_m2s2: [0.5, 0.2, 0.05]
})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "HRRR native"
assert html =~ "300.0"
end
end
describe "Show interactive events" do
import Microwaveprop.AccountsFixtures
defp admin_fixture do
user = user_fixture()
{:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
user
end
test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
before_html = render(lv)
toggled = render_click(lv, "toggle_hrrr_profile", %{})
back = render_click(lv, "toggle_hrrr_profile", %{})
assert is_binary(toggled)
assert is_binary(back)
assert back =~ contact.station1 and before_html =~ contact.station1
end
test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "toggle_terrain", %{})
assert is_binary(html)
end
test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
assert is_binary(html)
html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
assert is_binary(html2)
end
test "sort obs table by date toggles sort direction", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
assert html =~ "Surface Observations"
end
test "sort obs table exercises every known field sort key", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do
html = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
assert html =~ "Surface Observations", "sort by #{field} lost the obs table"
end
end
test "sort soundings table exercises every known field sort key", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
for field <- ~w(station_name observed_at unknown_field) do
html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
assert html =~ "Soundings", "sort by #{field} lost the soundings table"
end
end
test "sort soundings table by date keeps the view rendering", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
assert html =~ "Soundings"
end
test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_click(lv, "toggle_flag", %{})
assert html =~ "Admins only"
refute Repo.get!(Contact, contact.id).flagged_invalid
end
test "toggle_flag inverts the flag for admins", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_flag", %{})
refreshed = Repo.get!(Contact, contact.id)
assert refreshed.flagged_invalid == true
render_click(lv, "toggle_flag", %{})
assert Repo.get!(Contact, contact.id).flagged_invalid == false
end
test "toggle_edit opens and closes the inline edit form", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}")
refute html_before =~ ~s(phx-submit="submit_edit")
html_open = render_click(lv, "toggle_edit", %{})
assert html_open =~ ~s(phx-submit="submit_edit")
html_closed = render_click(lv, "toggle_edit", %{})
refute html_closed =~ ~s(phx-submit="submit_edit")
end
test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html =
render_submit(lv, "submit_edit", %{
"edit" => %{"station1" => "N5XX"}
})
assert html =~ "logged in to edit"
end
test "submit_edit as admin applies a simple callsign change", %{conn: conn} do
contact = create_contact(%{station1: "N5OLD"})
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => "N5NEW",
"station2" => contact.station2,
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
assert html =~ "Contact updated"
assert Repo.get!(Contact, contact.id).station1 == "N5NEW"
end
test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do
owner = user_fixture()
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: owner.callsign,
station2: "K5TR",
qso_timestamp: ~U[2026-03-28 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("295"),
user_submitted: true,
submitter_email: owner.email
})
|> Ecto.Changeset.change(user_id: owner.id)
|> Repo.insert()
conn = log_in_user(conn, owner)
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => owner.callsign,
"station2" => "K9NEW",
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
assert html =~ "Contact updated"
assert Repo.get!(Contact, contact.id).station2 == "K9NEW"
end
test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do
contact = create_contact()
stranger = user_fixture()
conn = log_in_user(conn, stranger)
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_submit(lv, "submit_edit", %{
"edit" => %{
"station1" => contact.station1,
"station2" => "K9SUGGEST",
"grid1" => contact.grid1,
"grid2" => contact.grid2,
"mode" => contact.mode
}
})
assert Repo.get!(Contact, contact.id).station2 == contact.station2
assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1
assert html =~ "submitted" or html =~ "suggest" or html =~ "pending"
end
test "renders the contact detail with a real surface observation row", %{conn: conn} do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.SurfaceObservation
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.90,
lon: -97.02
})
{:ok, _} =
%SurfaceObservation{}
|> SurfaceObservation.changeset(%{
station_id: station.id,
observed_at: ~U[2026-03-28 18:00:00Z],
temp_f: 72.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Surface Observations"
end
test "renders the contact detail with a real sounding row", %{conn: conn} do
alias Microwaveprop.Weather
alias Microwaveprop.Weather.Sounding
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
{:ok, _} =
%Sounding{}
|> Sounding.changeset(%{
station_id: station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0
})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "KFWD"
end
test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do
contact = create_contact()
admin = admin_fixture()
conn = log_in_user(conn, admin)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
render_click(lv, "toggle_edit", %{})
html =
render_change(lv, "validate_edit", %{
"edit" => %{"station1" => "N5TYPED"}
})
assert html =~ ~s(phx-submit="submit_edit")
assert Repo.get!(Contact, contact.id).station1 == "W5XD"
end
end
end

File diff suppressed because it is too large Load diff

View file

@ -219,26 +219,26 @@ defmodule MicrowavepropWeb.MapLiveTest do
# time is actually adopted depends on valid_times, which is empty
# in test (no ScoresFiles); that's fine — we're testing the
# handler path exists and doesn't raise on malformed/new input.
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
test "select_time event is accepted and doesn't crash the LV",
%{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"})
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
test "select_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_time", %{"time" => "definitely-not-a-time"})
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
test "set_selected_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_hook(lv, "set_selected_time", %{"time" => "definitely-not-a-time"})
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
test "select_time with valid iso updates selected_time assign", %{conn: conn} do
@ -272,7 +272,6 @@ defmodule MicrowavepropWeb.MapLiveTest do
# The assertion is that the handler returns cleanly even in the
# "no HRRR profile" path (dev/test has empty .prop files).
render_click(lv, "point_detail", %{"lat" => 32.9, "lon" => -97.04})
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
end

View file

@ -110,10 +110,8 @@ defmodule MicrowavepropWeb.RoverLiveTest do
{:ok, view, _html} = live(conn, ~p"/rover")
send(view.pid, {:rover_progress, "Scoring band 10G", 5, 18})
_ = :sys.get_state(view.pid)
Process.sleep(20)
# Just verify the LV is alive — render reflects scoring_step but
# we don't assert on the exact text.
assert render(view) =~ "Rover planning"
end
end

View file

@ -52,7 +52,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
# and the process to stay alive.
render_hook(lv, "switch_tab", %{"tab" => "definitely-not-a-tab-#{System.unique_integer()}"})
assert Process.alive?(lv.pid)
assert render(lv) =~ "Submit Contact"
end
test "honors a known tab name", %{conn: conn} do

View file

@ -91,7 +91,7 @@ defmodule MicrowavepropWeb.TelemetryTest do
sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}"
{:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name)
assert Process.alive?(pid)
assert Process.whereis(sup_name) == pid
# Unlink before tearing down so the brutal-kill exit signal doesn't
# propagate to the test process (which trap_exit defaults to off).