defmodule MicrowavepropWeb.BeaconLiveTest do use MicrowavepropWeb.ConnCase use ExUnitProperties import Microwaveprop.AccountsFixtures import Microwaveprop.BeaconsFixtures import Phoenix.LiveViewTest alias Microwaveprop.Accounts alias Microwaveprop.Beacons defp promote_to_admin(user) do {:ok, user} = Accounts.admin_update_user(user, %{is_admin: true}) user end defp admin_user_fixture do promote_to_admin(user_fixture()) end defp approved_beacon_fixture(user, attrs \\ %{}) do beacon = beacon_fixture(user, attrs) {:ok, beacon} = Beacons.approve_beacon(beacon) beacon end describe "Index (public)" do test "lists approved beacons without logging in", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons") assert html =~ "Beacons" assert html =~ beacon.callsign end test "hides unapproved beacons from the public list", %{conn: conn} do _pending = beacon_fixture(user_fixture(), callsign: "W5PEN") {:ok, _view, html} = live(conn, ~p"/beacons") refute html =~ "W5PEN" end test "anonymous sees the Submit Beacon button", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/beacons") assert html =~ "Submit Beacon" end test "authenticated user sees the Submit Beacon button", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) {:ok, _view, html} = live(conn, ~p"/beacons") assert html =~ "Submit Beacon" end test "admin sees pending beacons section", %{conn: conn} do pending = beacon_fixture(user_fixture(), callsign: "W5PEN") conn = log_in_user(conn, admin_user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons") assert html =~ "Pending approval" assert html =~ pending.callsign end test "non-admin does not see pending beacons section", %{conn: conn} do _pending = beacon_fixture(user_fixture(), callsign: "W5PEN") conn = log_in_user(conn, user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons") refute html =~ "Pending approval" refute html =~ "W5PEN" end test "admin can approve a pending beacon", %{conn: conn} do pending = beacon_fixture(user_fixture(), callsign: "W5PEN") conn = log_in_user(conn, admin_user_fixture()) {:ok, view, _html} = live(conn, ~p"/beacons") html = view |> element("a[phx-click][data-confirm='Approve this beacon?']") |> render_click() assert html =~ "Approved #{pending.callsign}" assert Beacons.get_beacon!(pending.id).approved == true end end describe "Show (public)" do test "renders beacon details without logging in", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ beacon.callsign assert html =~ "10,368.1" end test "shows pending badge on unapproved beacons", %{conn: conn} do beacon = beacon_fixture(user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "Pending approval" end test "plot-path-to-beacon link pre-populates tx_power + gain to match beacon EIRP", %{conn: conn} do # power_mw: 10_000 → EIRP 40 dBm → default-gain 30 dBi → tx 10 dBm beacon = approved_beacon_fixture(user_fixture(), power_mw: 10_000.0) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "tx_power_dbm=10.0" assert html =~ "src_gain_dbi=30.0" end test "low-EIRP beacon clamps tx_power to 0 dBm floor and lowers gain", %{conn: conn} do # power_mw: 100 → EIRP 20 dBm → tx 0 dBm (clamped), gain 20 dBi beacon = approved_beacon_fixture(user_fixture(), power_mw: 100.0, callsign: "W5LOW") {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "tx_power_dbm=0.0" assert html =~ "src_gain_dbi=20.0" end end describe "Form" do test "authenticated non-admin can access /beacons/new", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) {:ok, _view, html} = live(conn, ~p"/beacons/new") assert html =~ "New beacon" end test "anonymous user can access /beacons/new", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/beacons/new") assert html =~ "New beacon" end test "anonymous submission creates an unapproved beacon with no user_id", %{conn: conn} do {:ok, form_live, _html} = live(conn, ~p"/beacons/new") attrs = valid_beacon_attrs(callsign: "W5ANON") assert {:ok, _view, html} = form_live |> form("#beacon-form", beacon: attrs) |> render_submit() |> follow_redirect(conn, ~p"/beacons") assert html =~ "awaiting admin approval" beacon = Enum.find(Beacons.list_pending_beacons(), fn b -> b.callsign == "W5ANON" end) assert beacon assert beacon.user_id == nil assert beacon.approved == false end test "non-admin submission creates an unapproved beacon", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) {:ok, form_live, _html} = live(conn, ~p"/beacons/new") attrs = valid_beacon_attrs() assert {:ok, _view, _html} = form_live |> form("#beacon-form", beacon: attrs) |> render_submit() |> follow_redirect(conn, ~p"/beacons") beacon = List.first(Beacons.list_pending_beacons()) assert beacon.callsign == attrs.callsign assert beacon.approved == false end test "admin edits a beacon", %{conn: conn} do admin = admin_user_fixture() beacon = beacon_fixture(admin) conn = log_in_user(conn, admin) {:ok, form_live, _} = live(conn, ~p"/beacons/#{beacon}/edit") assert {:ok, _view, html} = form_live |> form("#beacon-form", beacon: %{power_mw: 25_000.0}) |> render_submit() |> follow_redirect(conn, ~p"/beacons") assert html =~ "Beacon updated" end test "non-admin cannot access /beacons/:id/edit", %{conn: conn} do beacon = beacon_fixture(user_fixture()) conn = log_in_user(conn, user_fixture()) assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/#{beacon}/edit") end end describe "Show — lazy coverage" do test "does not compute the estimate or embed cells on mount", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 10_368.1) {:ok, view, html} = live(conn, ~p"/beacons/#{beacon}") # Map div exists but data-cells is absent or empty until the user # flips the toggle — we don't want to pay the RangeEstimate cost # on every page load. refute html =~ "data-cells" assert has_element?(view, "input[phx-click='toggle_coverage']") end test "flipping the toggle computes the estimate and pushes coverage data", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 10_368.1) {:ok, view, html} = live(conn, ~p"/beacons/#{beacon}") # Before the click the coverage info bar is absent. refute html =~ "cells rendered" render_click(element(view, "input[phx-click='toggle_coverage']")) html = render(view) # After toggling on, the coverage info bar (which only renders # when @estimate is populated and @show_coverage is true) shows. assert html =~ "cells rendered" assert html =~ "Atm loss" end test "toggle_coverage is a no-op for beacons below 5.76 GHz", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), frequency_mhz: 1296.0) {:ok, view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "5.76 GHz and up only" refute html =~ "data-cells" refute html =~ "cells rendered" # Even if a client-side hack forces the click, the server refuses. render_click(view, "toggle_coverage") refute render(view) =~ "cells rendered" end end describe "Show — extra rendering + event coverage" do test "notes section renders when beacon has notes", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), notes: "Co-located with WX radar, 12h/day.") {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "Notes" assert html =~ "Co-located with WX radar, 12h/day." end test "on_the_air=false renders the 'No' ghost badge instead of success", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), on_the_air: false) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "badge-ghost" # HEEx interpolation may emit whitespace — assert on the # class-to-text sequence rather than `>No<` tightly. assert Regex.match?(~r/badge-ghost[^>]*>\s*No\s*Approve<" end test "handle_info({:updated, beacon}) refreshes the displayed callsign", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), callsign: "W5OLD") {:ok, view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "W5OLD" # Preload :user onto the updated struct — the production broadcast # path emits NotLoaded user and the template derefs beacon.user.callsign. updated = beacon |> Microwaveprop.Repo.preload(:user) |> Map.put(:callsign, "W5NEW") send(view.pid, {:updated, updated}) assert render(view) =~ "W5NEW" end test "handle_info({:deleted, beacon}) redirects back to the index with a flash", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture()) {:ok, view, _html} = live(conn, ~p"/beacons/#{beacon}") send(view.pid, {:deleted, beacon}) assert_redirect(view, ~p"/beacons") end test "handle_info for a different beacon id is a no-op", %{conn: conn} do beacon = approved_beacon_fixture(user_fixture(), callsign: "W5STAY") other = approved_beacon_fixture(user_fixture(), callsign: "W5OTHER") {:ok, view, _html} = live(conn, ~p"/beacons/#{beacon}") # An unrelated beacon update should NOT change the visible callsign. {:ok, other_updated} = Beacons.update_beacon(other, %{callsign: "W5DIFF"}) send(view.pid, {:updated, other_updated}) html = render(view) assert html =~ "W5STAY" refute html =~ "W5DIFF" end end describe "Show — edge cases on display fields" do test "numeric bearing renders with a degree symbol", %{conn: conn} do # bearing_label/1 is a 3-clause dispatch; numeric values hit the # fallback and produce "NNN°". beacon = approved_beacon_fixture(user_fixture(), bearing: "185") {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "185°" end test "'Plot path to beacon' link omits tx_power/gain for zero-power beacons", %{conn: conn} do # baseline_tx_for_eirp/1 has a `power_mw <= 0 -> {nil, nil}` # clause that maybe_put then drops from the URL query string. # Assert neither key appears when power_mw is 0.0. beacon = approved_beacon_fixture( user_fixture(), callsign: "W5ZER", power_mw: 0.0 ) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ "destination=" refute html =~ "tx_power_dbm=" refute html =~ "src_gain_dbi=" end test "non-admin unauthenticated user clicking 'approve' is rejected with a flash", %{conn: conn} do # The approve handler is rendered for admins only but the server # still defends the admin check. Simulate a crafted client pushing # the event manually with no scope; the flash branch should fire. pending = beacon_fixture(user_fixture(), callsign: "W5DEF") {:ok, view, _html} = live(conn, ~p"/beacons/#{pending}") render_click(view, "approve") # Still pending — no admin promotion. refute Beacons.get_beacon!(pending.id).approved assert render(view) =~ "Admins only." end end describe "Show — property: URL beacon id round-trip" do property "mounting /beacons/:id for a created beacon always renders its callsign" do # Wrap each run in a sandbox checkout so Repo inserts are isolated. check all(suffix <- integer(1..1_000_000), max_runs: 5) do callsign = "W5P#{Integer.to_string(suffix, 36)}" |> String.upcase() |> String.slice(0, 6) beacon = approved_beacon_fixture(user_fixture(), callsign: callsign) conn = Phoenix.ConnTest.build_conn() {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ callsign end end end end