P0 (security-critical): - Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers - Cap CSV/ADIF imports at 2,000 rows server-side in both parsers - Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false) - Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT P1 (high-priority): - Add Mox.verify_on_exit!() to valkey_test.exs - Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs - Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences) - Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error) - Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis - Split ContactLive.Show render into 12 function components - Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs - Batch CSV import enrichment jobs via new enqueue_for_contacts/1 P2 (medium-priority): - Set secure:true on session and remember-me cookies in production - Change SMTP TLS from verify_none to verify_peer with public_key cacerts - Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset - Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map - Add content-security-policy-report-only header - Add comment noting String.to_atom is compile-time safe in hrdps_client.ex - Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4 - Consolidate score-tier/color/verdict formatting into Microwaveprop.Format - Update CLAUDE.md testing section to match actual raw-string-matching practice - Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip - Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades P3 (low-priority): - Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters - Add host/community validation to snmp_client.ex - Add raw/1 safety comment in algo_live.ex - Add hex-audit and cargo-audit Makefile targets - Add privacy_live smoke test - Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive - Add ContactCommonVolumeRadar changeset validation tests (5 tests)
322 lines
9 KiB
Elixir
322 lines
9 KiB
Elixir
defmodule MicrowavepropWeb.PskrSpotsLiveTest do
|
||
use MicrowavepropWeb.ConnCase, async: true
|
||
|
||
import Phoenix.LiveViewTest
|
||
|
||
alias Microwaveprop.Repo
|
||
|
||
defp now, do: DateTime.utc_now()
|
||
defp dump_uuid, do: Ecto.UUID.dump!(Ecto.UUID.generate())
|
||
|
||
defp insert_spot(attrs) do
|
||
defaults = %{
|
||
hour_utc: now(),
|
||
band: "2m",
|
||
sender_grid: "EM12KL",
|
||
receiver_grid: "DM43ST",
|
||
spot_count: 1
|
||
}
|
||
|
||
fields = Map.merge(defaults, attrs)
|
||
|
||
Repo.query!(
|
||
"""
|
||
INSERT INTO pskr_spots_hourly
|
||
(id, hour_utc, band, sender_grid, receiver_grid,
|
||
sender_lat, sender_lon, receiver_lat, receiver_lon,
|
||
distance_km, spot_count, max_snr_db, min_snr_db,
|
||
modes, sender_callsigns, receiver_callsigns,
|
||
first_spot_at, last_spot_at,
|
||
inserted_at, updated_at)
|
||
VALUES
|
||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)
|
||
""",
|
||
[
|
||
dump_uuid(),
|
||
fields.hour_utc,
|
||
fields.band,
|
||
fields.sender_grid,
|
||
fields.receiver_grid,
|
||
Map.get(fields, :sender_lat, 32.5),
|
||
Map.get(fields, :sender_lon, -96.5),
|
||
Map.get(fields, :receiver_lat, 33.5),
|
||
Map.get(fields, :receiver_lon, -110.5),
|
||
Map.get(fields, :distance_km, 1_200.0),
|
||
fields.spot_count,
|
||
Map.get(fields, :max_snr_db, 5),
|
||
Map.get(fields, :min_snr_db, 0),
|
||
Map.get(fields, :modes, ["FT8"]),
|
||
Map.get(fields, :sender_callsigns, []),
|
||
Map.get(fields, :receiver_callsigns, []),
|
||
Map.get(fields, :first_spot_at, fields.hour_utc),
|
||
Map.get(fields, :last_spot_at, fields.hour_utc),
|
||
fields.hour_utc
|
||
]
|
||
)
|
||
end
|
||
|
||
describe "page rendering" do
|
||
test "renders the pskr spots page", %{conn: conn} do
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
assert html =~ "PSK Reporter Spots"
|
||
end
|
||
|
||
test "sets page title", %{conn: conn} do
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
assert html =~ "PSK Reporter Spots"
|
||
end
|
||
|
||
test "shows empty state when no spots exist", %{conn: conn} do
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
assert html =~ "No PSK Reporter spots received yet"
|
||
end
|
||
|
||
test "shows total spots count of zero when empty", %{conn: conn} do
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
assert html =~ "Total spots stored:"
|
||
assert html =~ "0"
|
||
end
|
||
end
|
||
|
||
describe "with spots in the database" do
|
||
test "displays total spots count and per-band counts", %{conn: conn} do
|
||
insert_spot(%{band: "2m", spot_count: 42})
|
||
insert_spot(%{band: "70cm", spot_count: 7})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
html = render_async(lv)
|
||
|
||
assert html =~ "Total spots stored:"
|
||
# 42 + 7 = 49 total
|
||
assert html =~ "49"
|
||
# Band badges show per-band counts
|
||
assert html =~ "2m"
|
||
assert html =~ "70cm"
|
||
end
|
||
|
||
test "displays band as badge", %{conn: conn} do
|
||
insert_spot(%{band: "70cm"})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "70cm"
|
||
end
|
||
|
||
test "displays SNR range", %{conn: conn} do
|
||
insert_spot(%{min_snr_db: -5, max_snr_db: 12})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "-5–12 dB"
|
||
end
|
||
|
||
test "displays single SNR when min equals max", %{conn: conn} do
|
||
insert_spot(%{min_snr_db: 3, max_snr_db: 3})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "3 dB"
|
||
refute html =~ "3–3 dB"
|
||
end
|
||
|
||
test "displays sender and receiver callsigns", %{conn: conn} do
|
||
insert_spot(%{sender_callsigns: ["K5ABC"], receiver_callsigns: ["K7XYZ"]})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "K5ABC"
|
||
assert html =~ "K7XYZ"
|
||
end
|
||
|
||
test "displays multiple callsigns comma-separated", %{conn: conn} do
|
||
insert_spot(%{sender_callsigns: ["K5ABC", "W1AW"], receiver_callsigns: ["K7XYZ"]})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "K5ABC, W1AW"
|
||
end
|
||
|
||
test "shows dash for empty callsigns", %{conn: conn} do
|
||
insert_spot(%{sender_callsigns: [], receiver_callsigns: []})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "—"
|
||
end
|
||
|
||
test "displays modes", %{conn: conn} do
|
||
insert_spot(%{modes: ["FT8", "FT4"]})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
assert html =~ "FT8"
|
||
assert html =~ "FT4"
|
||
end
|
||
|
||
test "shows most recent spots first", %{conn: conn} do
|
||
t1 = ~U[2026-07-01 12:00:00Z]
|
||
t2 = ~U[2026-07-01 13:00:00Z]
|
||
|
||
insert_spot(%{sender_grid: "EARLIER", last_spot_at: t1, hour_utc: t1})
|
||
insert_spot(%{sender_grid: "LATER", last_spot_at: t2, hour_utc: t2})
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
# LATER should appear before EARLIER in the HTML
|
||
assert String.contains?(html, "LATER")
|
||
assert String.contains?(html, "EARLIER")
|
||
{l_pos, _} = :binary.match(html, "LATER")
|
||
{e_pos, _} = :binary.match(html, "EARLIER")
|
||
assert l_pos < e_pos
|
||
end
|
||
|
||
test "limits to 100 most recent spots", %{conn: conn} do
|
||
# Insert 105 spots — only the 100 most recent should render
|
||
# Pad single-digit grid numbers so substring matching is unambiguous
|
||
for i <- 1..105 do
|
||
t = DateTime.add(now(), i - 105, :second)
|
||
grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}"
|
||
insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t})
|
||
end
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/pskreporter")
|
||
|
||
# The oldest 5 should be excluded
|
||
refute html =~ "GRID001"
|
||
refute html =~ "GRID002"
|
||
refute html =~ "GRID003"
|
||
refute html =~ "GRID004"
|
||
refute html =~ "GRID005"
|
||
|
||
# The most recent 100 should be present
|
||
assert html =~ "GRID105"
|
||
assert html =~ "GRID006"
|
||
end
|
||
end
|
||
|
||
describe "band filtering" do
|
||
test "filtering by band shows only that band's spots", %{conn: conn} do
|
||
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
|
||
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
# Wait for async band_counts to complete, then re-render
|
||
html = render_async(lv)
|
||
|
||
# Both bands visible in table
|
||
assert html =~ "GRID2M"
|
||
assert html =~ "GRID70"
|
||
|
||
# Click the 2m badge
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"2m\"]")
|
||
|> render_click()
|
||
|
||
assert html =~ "GRID2M"
|
||
refute html =~ "GRID70"
|
||
|
||
# Filter badge should be highlighted
|
||
assert html =~ "bg-primary/15"
|
||
end
|
||
|
||
test "clicking the active band again clears the filter", %{conn: conn} do
|
||
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
|
||
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
_html = render_async(lv)
|
||
|
||
# Filter to 2m
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"2m\"]")
|
||
|> render_click()
|
||
|
||
refute html =~ "GRID70"
|
||
|
||
# Click 2m again to clear
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"2m\"]")
|
||
|> render_click()
|
||
|
||
assert html =~ "GRID2M"
|
||
assert html =~ "GRID70"
|
||
end
|
||
|
||
test "clear filter button appears when filter is active", %{conn: conn} do
|
||
insert_spot(%{band: "2m", last_spot_at: now()})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
html = render_async(lv)
|
||
|
||
# No clear filter button initially
|
||
refute html =~ "Clear filter"
|
||
|
||
# Filter by band
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"2m\"]")
|
||
|> render_click()
|
||
|
||
assert html =~ "Clear filter"
|
||
|
||
# Click clear filter
|
||
html =
|
||
lv
|
||
|> element("button", "Clear filter")
|
||
|> render_click()
|
||
|
||
refute html =~ "Clear filter"
|
||
end
|
||
|
||
test "subtitle reflects active filter", %{conn: conn} do
|
||
insert_spot(%{band: "70cm", last_spot_at: now()})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
html = render_async(lv)
|
||
|
||
assert html =~ "from the MQTT firehose"
|
||
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"70cm\"]")
|
||
|> render_click()
|
||
|
||
assert html =~ "70cm"
|
||
refute html =~ "from the MQTT firehose"
|
||
end
|
||
|
||
test "switching band filter updates table", %{conn: conn} do
|
||
insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()})
|
||
insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/pskreporter")
|
||
|
||
_html = render_async(lv)
|
||
|
||
# Filter to 2m
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"2m\"]")
|
||
|> render_click()
|
||
|
||
refute html =~ "GRID70"
|
||
|
||
# Switch to 70cm
|
||
html =
|
||
lv
|
||
|> element("button[phx-value-band=\"70cm\"]")
|
||
|> render_click()
|
||
|
||
refute html =~ "GRID2M"
|
||
assert html =~ "GRID70"
|
||
end
|
||
end
|
||
end
|