feat(monitors): schema migration + remove user self-service creation

- Add hardware/config fields migration to beacon_monitors table
- Update BeaconMonitor schema with provision/config changesets
- Add context functions: create_hardware, update_config, list_all_monitors
- Remove user-facing monitor creation (browser POST + API POST)
- Update settings page: show assigned monitors table with hardware info
- Update profile page: show assigned monitors, remove register links
- Fix all tests to match new API
This commit is contained in:
Graham McIntire 2026-07-21 18:28:51 -05:00
parent 739984d3bc
commit fb49eb016d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 328 additions and 196 deletions

View file

@ -1,48 +1,148 @@
defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
The BeaconMonitors context: manages the monitor stations a user
has registered. Each monitor has a unique random token the remote
program uses to authenticate its reports.
The BeaconMonitors context: manages the physical SDR-based monitor
hardware assigned to users.
Each monitor has a unique random token the `propmonitor` client uses
to authenticate its measurement uploads.
"""
import Ecto.Query
alias Ecto.Query.CastError
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@token_bytes 32
# ── User-facing queries ──────────────────────────────────────────
@doc """
Returns all monitors for the given user, newest first.
Returns all monitors assigned to the given user, newest first.
Preloads the beacon for display.
"""
@spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()]
def list_monitors_for_user(%User{id: user_id}) do
Repo.all(
from m in BeaconMonitor,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at]
order_by: [desc: m.inserted_at],
preload: [:beacon]
)
end
@doc """
Creates a new monitor for the given user with a freshly generated token.
Returns a single monitor. Preloads beacon and user relations.
"""
@spec get_monitor!(Ecto.UUID.t()) :: BeaconMonitor.t()
def get_monitor!(monitor_id) do
BeaconMonitor |> Repo.get!(monitor_id) |> Repo.preload([:beacon, :user])
end
@doc """
Returns a single monitor if owned by the given user, nil otherwise.
"""
@spec get_monitor_for_user(Ecto.UUID.t(), User.t()) :: BeaconMonitor.t() | nil
def get_monitor_for_user(monitor_id, %User{id: user_id}) do
Repo.one(
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id,
preload: [:beacon]
)
end
# ── Admin queries ────────────────────────────────────────────────
@doc """
Returns all monitors, newest first, with user and beacon preloaded.
Accepts preload overrides via options.
"""
@spec list_all_monitors(keyword()) :: [BeaconMonitor.t()]
def list_all_monitors(opts \\ []) do
preloads = Keyword.get(opts, :preload, [:user, :beacon, :assigned_by])
from(m in BeaconMonitor, order_by: [desc: m.inserted_at])
|> Repo.all()
|> Repo.preload(preloads)
end
# ── Test / convenience helpers ───────────────────────────────────
@doc """
Creates a monitor and assigns it to the given user. The user acts as
both the assigned owner and the creating admin. Used by tests and
any legacy callers.
"""
@spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_monitor(%User{} = user, attrs) do
%BeaconMonitor{user_id: user.id, token: generate_token()}
|> BeaconMonitor.changeset(attrs)
def create_monitor(%User{} = user, attrs) when is_list(attrs) or is_map(attrs) do
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put("user_id", user.id)
create_hardware(user, attrs)
end
# ── Admin provisioning ───────────────────────────────────────────
@doc """
Creates a new hardware monitor with the given attrs. Generates a
unique auth token. Expects `user_id` and `assigned_by_id` to be set
in attrs.
"""
@spec create_hardware(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_hardware(%User{} = admin, attrs) do
attrs = Map.put(attrs, "assigned_by_id", admin.id)
%BeaconMonitor{token: generate_token()}
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.insert()
end
@doc """
Deletes a monitor owned by the given user.
Updates the monitor's configuration (beacon, frequency, mode, etc).
Used by both admins and the assigned user.
"""
@spec update_config(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_config(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.config_changeset(attrs)
|> Repo.update()
end
Returns `{:error, :not_found}` if the monitor does not exist or
belongs to another user.
@doc """
Updates the monitor's hardware provisioning fields. Admin-only.
"""
@spec update_hardware(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_hardware(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.update()
end
@doc """
Reassigns a monitor to a different user. Returns `{:error, :not_found}`
if the target user does not exist.
"""
@spec assign_to_user(BeaconMonitor.t(), User.t(), User.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found | Ecto.Changeset.t()}
def assign_to_user(%BeaconMonitor{} = monitor, %User{id: _} = admin, %User{id: new_user_id}) do
monitor
|> BeaconMonitor.provision_changeset(%{
user_id: new_user_id,
assigned_by_id: admin.id
})
|> Repo.update()
end
@doc """
Deletes a monitor. Works for both admin and the assigned user.
"""
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()}
{:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
@ -53,9 +153,24 @@ defmodule Microwaveprop.BeaconMonitors do
monitor -> Repo.delete(monitor)
end
rescue
Ecto.Query.CastError -> {:error, :not_found}
CastError -> {:error, :not_found}
end
@doc """
Deletes a monitor by its ID without user scoping. Admin-only.
"""
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor!(monitor_id) do
case Repo.get(BeaconMonitor, monitor_id) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
CastError -> {:error, :not_found}
end
# ── Auth / heartbeat ─────────────────────────────────────────────
@doc """
Looks up a monitor by its token. Returns nil if not found.
"""
@ -81,11 +196,11 @@ defmodule Microwaveprop.BeaconMonitors do
end
@doc """
Returns a blank changeset for rendering the new-monitor form.
Returns a blank changeset for rendering a new-monitor form.
"""
@spec change_monitor(map()) :: Ecto.Changeset.t()
def change_monitor(attrs \\ %{}) do
BeaconMonitor.changeset(%BeaconMonitor{}, attrs)
BeaconMonitor.provision_changeset(%BeaconMonitor{}, attrs)
end
defp generate_token do

View file

@ -1,8 +1,11 @@
defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
@moduledoc """
A beacon monitor is a remote station running the monitor program
that reports beacon reception data. Each monitor is owned by a user
and identified by a random token the program uses to authenticate.
A beacon monitor is a physical SDR-based hardware unit assigned to a
user. The unit runs the `propmonitor` client which reports beacon
reception measurements back to Microwaveprop.
Each monitor has a unique random token the client uses to authenticate
its measurement uploads.
"""
use Ecto.Schema
@ -10,15 +13,36 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Beacons.Beacon
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "beacon_monitors" do
# Identity / label
field :name, :string
field :token, :string
field :last_seen_at, :utc_datetime
# Hardware identity
field :hardware_type, :string
field :hardware_id, :string
field :firmware_version, :string
# Antenna / installation
field :antenna_type, :string
field :antenna_gain_dbi, :float
field :lat, :float
field :lon, :float
# Active configuration (admin + assigned user can change)
field :config_frequency_hz, :integer
field :config_integration_s, :integer
field :config_mode, :string
# Relationships
belongs_to :user, User
belongs_to :beacon, Beacon
belongs_to :assigned_by, User
timestamps(type: :utc_datetime)
end
@ -26,8 +50,8 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
@type t :: %__MODULE__{}
@doc """
Changeset for user-controlled fields (name only for now).
Token and user_id are assigned by the context, not cast from user input.
General changeset allows basic fields. Context-level functions
enforce which caller-role can set which fields.
"""
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(monitor, attrs) do
@ -36,4 +60,52 @@ defmodule Microwaveprop.BeaconMonitors.BeaconMonitor do
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
end
@doc """
Provisioning changeset used by admins to register new hardware.
Casts all hardware-identity, antenna, and assignment fields.
"""
@spec provision_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def provision_changeset(monitor, attrs) do
monitor
|> cast(attrs, [
:name,
:hardware_type,
:hardware_id,
:firmware_version,
:antenna_type,
:antenna_gain_dbi,
:lat,
:lon,
:user_id,
:assigned_by_id
])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:hardware_type, max: 100)
|> validate_length(:hardware_id, max: 255)
|> validate_length(:firmware_version, max: 50)
|> validate_length(:antenna_type, max: 100)
|> validate_number(:antenna_gain_dbi, greater_than_or_equal_to: -20, less_than_or_equal_to: 60)
|> validate_number(:lat, greater_than_or_equal_to: -90, less_than_or_equal_to: 90)
|> validate_number(:lon, greater_than_or_equal_to: -180, less_than_or_equal_to: 180)
|> foreign_key_constraint(:user_id)
|> foreign_key_constraint(:assigned_by_id)
end
@doc """
Configuration changeset used by admins and assigned users to update
what the monitor is listening for.
"""
@spec config_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def config_changeset(monitor, attrs) do
monitor
|> cast(attrs, [:name, :beacon_id, :config_frequency_hz, :config_integration_s, :config_mode])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:config_mode, max: 50)
|> validate_number(:config_frequency_hz, greater_than: 0)
|> validate_number(:config_integration_s, greater_than_or_equal_to: 5, less_than_or_equal_to: 3600)
|> foreign_key_constraint(:beacon_id)
end
end

View file

@ -84,22 +84,6 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
end
@spec create_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create_monitor(conn, params) do
user = conn.assigns.current_api_user
attrs = Map.take(params, ["name"])
case BeaconMonitors.create_monitor(user, attrs) do
{:ok, monitor} ->
conn
|> put_status(:created)
|> json(BeaconMonitorJSON.show(%{monitor: monitor}))
{:error, changeset} ->
ErrorJSON.send_changeset(conn, changeset)
end
end
@spec delete_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete_monitor(conn, %{"id" => id}) do
user = conn.assigns.current_api_user

View file

@ -3,28 +3,6 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
alias Microwaveprop.BeaconMonitors
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"beacon_monitor" => params}) do
user = conn.assigns.current_scope.user
case BeaconMonitors.create_monitor(user, params) do
{:ok, monitor} ->
conn
|> put_flash(:info, "Monitor '#{monitor.name}' created. Copy its token below.")
|> redirect(to: ~p"/users/settings")
{:error, changeset} ->
message =
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, _} -> msg end)
|> Enum.map_join("; ", fn {k, v} -> "#{k}: #{Enum.join(v, ", ")}" end)
conn
|> put_flash(:error, "Could not create monitor (#{message}).")
|> redirect(to: ~p"/users/settings")
end
end
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user

View file

@ -105,10 +105,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
defp assign_beacon_monitors(conn, _opts) do
user = conn.assigns.current_scope.user
conn
|> assign(:beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
|> assign(:beacon_monitor_changeset, BeaconMonitors.change_monitor())
assign(conn, :beacon_monitors, BeaconMonitors.list_monitors_for_user(user))
end
defp assign_api_tokens(conn, _opts) do

View file

@ -91,49 +91,23 @@
<section id="beacon-monitors" class="space-y-4">
<.header>
Beacon monitors
Assigned beacon monitors
<:subtitle>
Register remote monitor stations. Each monitor gets a unique token the
monitor program uses to authenticate its reports.
Physical monitor hardware assigned to you by an admin. Each unit
reports beacon reception data back to Microwaveprop.
</:subtitle>
</.header>
<div class="alert alert-warning text-sm">
<.icon name="hero-exclamation-triangle" class="size-4" />
<span>Not working yet — monitor client + ingestion pipeline still in development.</span>
</div>
<.form
:let={f}
for={@beacon_monitor_changeset}
as={:beacon_monitor}
action={~p"/users/beacon-monitors"}
id="create_beacon_monitor"
>
<label for={f[:name].id} class="label mb-1">Monitor name</label>
<div class="flex items-center gap-2">
<input
type="text"
name={f[:name].name}
id={f[:name].id}
value={Phoenix.HTML.Form.normalize_value("text", f[:name].value)}
placeholder="e.g. Shack Pi"
class="input flex-1"
required
/>
<.button variant="primary" phx-disable-with="Adding...">Add monitor</.button>
</div>
</.form>
<%= if @beacon_monitors == [] do %>
<p class="text-sm opacity-70">No monitors registered yet.</p>
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Hardware</th>
<th>Monitoring</th>
<th>Last seen</th>
<th class="w-1"></th>
</tr>
@ -142,8 +116,28 @@
<%= for monitor <- @beacon_monitors do %>
<tr>
<td class="font-semibold">{monitor.name}</td>
<td>
<code class="text-xs break-all select-all">{monitor.token}</code>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}<br />
<span class="opacity-60 text-xs">{monitor.hardware_id}</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<span class="opacity-60">
{monitor.beacon.frequency_mhz} MHz
</span>
<% else %>
<span class="opacity-50">Not configured</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>

View file

@ -169,48 +169,56 @@ defmodule MicrowavepropWeb.UserProfileLive do
<div class="card-body">
<h2 class="card-title">Beacon monitors</h2>
<p class="text-sm opacity-70">
Remote monitor stations that report beacon reception data.
Physical monitor hardware assigned to you.
</p>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Hardware</th>
<th>Monitoring</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
<tr :for={monitor <- @beacon_monitors}>
<td class="font-semibold">{monitor.name}</td>
<td class="text-sm">
<%= if monitor.hardware_type do %>
{monitor.hardware_type}<br />
<span class="opacity-60 text-xs">{monitor.hardware_id}</span>
<% else %>
<span class="opacity-50">&mdash;</span>
<% end %>
</td>
<td class="text-sm">
<%= if monitor.beacon do %>
<.link
navigate={~p"/beacons/#{monitor.beacon.id}"}
class="link link-hover font-mono"
>
{monitor.beacon.callsign}
</.link>
<span class="opacity-60"> ({monitor.beacon.frequency_mhz} MHz)</span>
<% else %>
<span class="opacity-50">Not configured</span>
<% end %>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
</tr>
</tbody>
</table>
</div>
<%= if @beacon_monitors == [] do %>
<div class="flex items-center justify-between">
<p class="text-sm opacity-70">No monitors registered yet.</p>
<.link navigate={~p"/users/settings#beacon-monitors"} class="btn btn-primary btn-sm">
<.icon name="hero-plus" class="size-4" /> Register a monitor
</.link>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra table-sm">
<thead>
<tr>
<th>Name</th>
<th>Token</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
<tr :for={monitor <- @beacon_monitors}>
<td class="font-semibold">{monitor.name}</td>
<td>
<code class="text-xs select-all">{String.slice(monitor.token, -8, 8)}</code>
</td>
<td class="text-sm opacity-70">
<%= if monitor.last_seen_at do %>
{Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")}
<% else %>
never
<% end %>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end">
<.link navigate={~p"/users/settings#beacon-monitors"} class="btn btn-primary btn-sm">
<.icon name="hero-plus" class="size-4" /> Register another monitor
</.link>
</div>
<p class="text-sm opacity-70">No monitors assigned to you yet.</p>
<% end %>
</div>
</div>

View file

@ -318,7 +318,6 @@ defmodule MicrowavepropWeb.Router do
delete "/me/api-tokens/:id", MeController, :revoke_token
get "/me/beacon-monitors", MeController, :list_monitors
post "/me/beacon-monitors", MeController, :create_monitor
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
post "/contacts", ContactController, :create
@ -375,7 +374,6 @@ defmodule MicrowavepropWeb.Router do
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
post "/users/api-tokens", ApiTokenController, :create

View file

@ -0,0 +1,30 @@
defmodule Microwaveprop.Repo.Migrations.AddHardwareFieldsToBeaconMonitors do
use Ecto.Migration
def change do
alter table(:beacon_monitors) do
# Hardware identity
add :hardware_type, :string
add :hardware_id, :string
add :firmware_version, :string
# Antenna / installation
add :antenna_type, :string
add :antenna_gain_dbi, :float
add :lat, :float
add :lon, :float
# Active configuration (admin+user-settable)
add :beacon_id, references(:beacons, type: :binary_id, on_delete: :nilify_all)
add :config_frequency_hz, :bigint
add :config_integration_s, :integer
add :config_mode, :string
# Assignment audit trail
add :assigned_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
end
create index(:beacon_monitors, [:beacon_id])
create index(:beacon_monitors, [:assigned_by_id])
end
end

View file

@ -105,25 +105,10 @@ defmodule MicrowavepropWeb.Api.V1.MeControllerTest do
end
end
describe "beacon monitor CRUD" do
test "creates, lists, and deletes a beacon monitor", %{authed: conn} do
created =
conn
|> post(~p"/api/v1/me/beacon-monitors", %{"name" => "Tower"})
|> json_response(201)
id = created["data"]["id"]
describe "beacon monitors" do
test "lists monitors", %{authed: conn} do
list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200)
assert Enum.any?(list["data"], &(&1["id"] == id))
conn = delete(conn, ~p"/api/v1/me/beacon-monitors/#{id}")
assert response(conn, 204)
end
test "422 when creating with empty name", %{authed: conn} do
conn = post(conn, ~p"/api/v1/me/beacon-monitors", %{"name" => ""})
assert json_response(conn, 422)
assert list["data"] == []
end
test "404 when deleting unknown monitor", %{authed: conn} do

View file

@ -7,39 +7,6 @@ defmodule MicrowavepropWeb.BeaconMonitorControllerTest do
setup :register_and_log_in_user
describe "POST /users/beacon-monitors" do
test "creates a monitor for the current user", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => "Shack Pi"}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Shack Pi"
assert [monitor] = BeaconMonitors.list_monitors_for_user(user)
assert monitor.name == "Shack Pi"
assert byte_size(monitor.token) > 0
end
test "shows an error when name is blank", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/beacon-monitors", %{
"beacon_monitor" => %{"name" => ""}
})
assert redirected_to(conn) == ~p"/users/settings"
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "name"
assert BeaconMonitors.list_monitors_for_user(user) == []
end
test "redirects unauthenticated users to login" do
conn = post(build_conn(), ~p"/users/beacon-monitors", %{"beacon_monitor" => %{"name" => "Nope"}})
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "DELETE /users/beacon-monitors/:id" do
test "deletes a monitor owned by the current user", %{conn: conn, user: user} do
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"})

View file

@ -246,24 +246,28 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "Beacon monitors"
assert html =~ "No monitors registered yet"
assert html =~ "Register a monitor"
assert html =~ "No monitors assigned to you yet"
end
test "owners see their registered beacon monitors", %{conn: conn} do
test "owners see their assigned beacon monitors", %{conn: conn} do
user = user_fixture(%{callsign: "W5ISP"})
{:ok, monitor} = BeaconMonitors.create_monitor(user, %{name: "Shack Pi"})
{:ok, monitor} =
BeaconMonitors.create_hardware(user, %{
"name" => "Shack Pi",
"hardware_type" => "RTL-SDR",
"hardware_id" => "SN-001",
"user_id" => user.id
})
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/u/#{user.callsign}")
assert html =~ "Beacon monitors"
assert html =~ "Shack Pi"
assert html =~ "RTL-SDR"
assert html =~ "SN-001"
assert html =~ "never"
assert html =~ "Register another monitor"
# Token is shown truncated (last 8 chars)
assert html =~ String.slice(monitor.token, -8, 8)
end
end
end