From 355bed178d134595c8900f3269b2cf3473b32e77 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 8 Apr 2026 11:29:50 -0500 Subject: [PATCH] Add beacon monitor registration and fix prod SMTP Users can now register beacon monitors on the settings page. Each monitor gets a unique random token the remote program will use to authenticate its reports. Name is the only user-supplied field for now; more will be added as the monitor protocol is defined. Also adds :gen_smtp to deps. Swoosh's SMTP adapter depends on :mimemail which lives in that package, and production was 500'ing when trying to send confirmation emails without it. --- lib/microwaveprop/beacon_monitors.ex | 74 ++++++++++++++ .../beacon_monitors/beacon_monitor.ex | 36 +++++++ .../controllers/beacon_monitor_controller.ex | 38 +++++++ .../controllers/user_settings_controller.ex | 10 ++ .../user_settings_html/edit.html.heex | 76 ++++++++++++++ lib/microwaveprop_web/router.ex | 3 + mix.exs | 1 + mix.lock | 2 + .../20260408154735_create_beacon_monitors.exs | 18 ++++ test/microwaveprop/beacon_monitors_test.exs | 99 +++++++++++++++++++ .../beacon_monitor_controller_test.exs | 65 ++++++++++++ 11 files changed, 422 insertions(+) create mode 100644 lib/microwaveprop/beacon_monitors.ex create mode 100644 lib/microwaveprop/beacon_monitors/beacon_monitor.ex create mode 100644 lib/microwaveprop_web/controllers/beacon_monitor_controller.ex create mode 100644 priv/repo/migrations/20260408154735_create_beacon_monitors.exs create mode 100644 test/microwaveprop/beacon_monitors_test.exs create mode 100644 test/microwaveprop_web/controllers/beacon_monitor_controller_test.exs diff --git a/lib/microwaveprop/beacon_monitors.ex b/lib/microwaveprop/beacon_monitors.ex new file mode 100644 index 00000000..1c413368 --- /dev/null +++ b/lib/microwaveprop/beacon_monitors.ex @@ -0,0 +1,74 @@ +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. + """ + + import Ecto.Query + + alias Microwaveprop.Accounts.User + alias Microwaveprop.BeaconMonitors.BeaconMonitor + alias Microwaveprop.Repo + + @token_bytes 32 + + @doc """ + Returns all monitors for the given user, newest first. + """ + 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] + ) + end + + @doc """ + Creates a new monitor for the given user with a freshly generated token. + """ + def create_monitor(%User{} = user, attrs) do + %BeaconMonitor{user_id: user.id, token: generate_token()} + |> BeaconMonitor.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Deletes a monitor owned by the given user. + + Returns `{:error, :not_found}` if the monitor does not exist or + belongs to another user. + """ + def delete_monitor(%User{id: user_id}, monitor_id) do + query = + from m in BeaconMonitor, + where: m.id == ^monitor_id and m.user_id == ^user_id + + case Repo.one(query) do + nil -> {:error, :not_found} + monitor -> Repo.delete(monitor) + end + rescue + Ecto.Query.CastError -> {:error, :not_found} + end + + @doc """ + Looks up a monitor by its token. Returns nil if not found. + """ + def get_monitor_by_token(token) when is_binary(token) do + Repo.get_by(BeaconMonitor, token: token) + end + + @doc """ + Returns a blank changeset for rendering the new-monitor form. + """ + def change_monitor(attrs \\ %{}) do + BeaconMonitor.changeset(%BeaconMonitor{}, attrs) + end + + defp generate_token do + @token_bytes + |> :crypto.strong_rand_bytes() + |> Base.url_encode64(padding: false) + end +end diff --git a/lib/microwaveprop/beacon_monitors/beacon_monitor.ex b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex new file mode 100644 index 00000000..3ff887bb --- /dev/null +++ b/lib/microwaveprop/beacon_monitors/beacon_monitor.ex @@ -0,0 +1,36 @@ +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. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Microwaveprop.Accounts.User + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "beacon_monitors" do + field :name, :string + field :token, :string + field :last_seen_at, :utc_datetime + + belongs_to :user, User + + timestamps(type: :utc_datetime) + end + + @doc """ + Changeset for user-controlled fields (name only for now). + Token and user_id are assigned by the context, not cast from user input. + """ + def changeset(monitor, attrs) do + monitor + |> cast(attrs, [:name]) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 100) + end +end diff --git a/lib/microwaveprop_web/controllers/beacon_monitor_controller.ex b/lib/microwaveprop_web/controllers/beacon_monitor_controller.ex new file mode 100644 index 00000000..be0690c8 --- /dev/null +++ b/lib/microwaveprop_web/controllers/beacon_monitor_controller.ex @@ -0,0 +1,38 @@ +defmodule MicrowavepropWeb.BeaconMonitorController do + use MicrowavepropWeb, :controller + + alias Microwaveprop.BeaconMonitors + + 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 + + def delete(conn, %{"id" => id}) do + user = conn.assigns.current_scope.user + + conn = + case BeaconMonitors.delete_monitor(user, id) do + {:ok, _monitor} -> put_flash(conn, :info, "Monitor deleted.") + {:error, :not_found} -> put_flash(conn, :error, "Monitor not found.") + end + + redirect(conn, to: ~p"/users/settings") + end +end diff --git a/lib/microwaveprop_web/controllers/user_settings_controller.ex b/lib/microwaveprop_web/controllers/user_settings_controller.ex index 629f00fa..887e1fe6 100644 --- a/lib/microwaveprop_web/controllers/user_settings_controller.ex +++ b/lib/microwaveprop_web/controllers/user_settings_controller.ex @@ -4,10 +4,12 @@ defmodule MicrowavepropWeb.UserSettingsController do import MicrowavepropWeb.UserAuth, only: [require_sudo_mode: 2] alias Microwaveprop.Accounts + alias Microwaveprop.BeaconMonitors alias MicrowavepropWeb.UserAuth plug :require_sudo_mode plug :assign_email_and_password_changesets + plug :assign_beacon_monitors def edit(conn, _params) do render(conn, :edit) @@ -74,4 +76,12 @@ defmodule MicrowavepropWeb.UserSettingsController do |> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:password_changeset, Accounts.change_user_password(user)) end + + 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()) + end end diff --git a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex index 6306e80d..ab1ccae6 100644 --- a/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex +++ b/lib/microwaveprop_web/controllers/user_settings_html/edit.html.heex @@ -46,4 +46,80 @@ Save Password + +
+ +
+ <.header> + Beacon monitors + <:subtitle> + Register remote monitor stations. Each monitor gets a unique token the + monitor program uses to authenticate its reports. + + + + <.form + :let={f} + for={@beacon_monitor_changeset} + as={:beacon_monitor} + action={~p"/users/beacon-monitors"} + id="create_beacon_monitor" + class="flex items-end gap-2" + > +
+ <.input + field={f[:name]} + type="text" + label="Monitor name" + placeholder="e.g. Shack Pi" + required + /> +
+ <.button variant="primary" phx-disable-with="Adding...">Add monitor + + + <%= if @beacon_monitors == [] do %> +

No monitors registered yet.

+ <% else %> +
+ + + + + + + + + + + <%= for monitor <- @beacon_monitors do %> + + + + + + + <% end %> + +
NameTokenLast seen
{monitor.name} + {monitor.token} + + <%= if monitor.last_seen_at do %> + {Calendar.strftime(monitor.last_seen_at, "%Y-%m-%d %H:%M UTC")} + <% else %> + never + <% end %> + + <.link + href={~p"/users/beacon-monitors/#{monitor.id}"} + method="delete" + class="btn btn-ghost btn-xs text-error" + data-confirm={"Delete monitor '#{monitor.name}'? Its token will stop working immediately."} + > + Delete + +
+
+ <% end %> +
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 7f54d5bd..aa8d8d97 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -86,6 +86,9 @@ defmodule MicrowavepropWeb.Router do get "/users/settings", UserSettingsController, :edit 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 end scope "/", MicrowavepropWeb do diff --git a/mix.exs b/mix.exs index 14a19121..9d58b8b3 100644 --- a/mix.exs +++ b/mix.exs @@ -58,6 +58,7 @@ defmodule Microwaveprop.MixProject do {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.2.0", sparse: "optimized", app: false, compile: false, depth: 1}, {:swoosh, "~> 1.16"}, + {:gen_smtp, "~> 1.2"}, {:req, "~> 0.5"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index e48d70b2..8bde97ec 100644 --- a/mix.lock +++ b/mix.lock @@ -20,6 +20,7 @@ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, + "gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -46,6 +47,7 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "polaris": {:hex, :polaris, "0.1.0", "dca61b18e3e801ecdae6ac9f0eca5f19792b44a5cb4b8d63db50fc40fc038d22", [:mix], [{:nx, "~> 0.5", [hex: :nx, repo: "hexpm", optional: false]}], "hexpm", "13ef2b166650e533cb24b10e2f3b8ab4f2f449ba4d63156e8c569527f206e2c2"}, "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"}, "styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"}, diff --git a/priv/repo/migrations/20260408154735_create_beacon_monitors.exs b/priv/repo/migrations/20260408154735_create_beacon_monitors.exs new file mode 100644 index 00000000..40eaef6e --- /dev/null +++ b/priv/repo/migrations/20260408154735_create_beacon_monitors.exs @@ -0,0 +1,18 @@ +defmodule Microwaveprop.Repo.Migrations.CreateBeaconMonitors do + use Ecto.Migration + + def change do + create table(:beacon_monitors, primary_key: false) do + add :id, :binary_id, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :name, :string, null: false + add :token, :string, null: false + add :last_seen_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:beacon_monitors, [:user_id]) + create unique_index(:beacon_monitors, [:token]) + end +end diff --git a/test/microwaveprop/beacon_monitors_test.exs b/test/microwaveprop/beacon_monitors_test.exs new file mode 100644 index 00000000..7244b968 --- /dev/null +++ b/test/microwaveprop/beacon_monitors_test.exs @@ -0,0 +1,99 @@ +defmodule Microwaveprop.BeaconMonitorsTest do + use Microwaveprop.DataCase + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.BeaconMonitors + alias Microwaveprop.BeaconMonitors.BeaconMonitor + + describe "create_monitor/2" do + test "creates a monitor with a generated token" do + user = user_fixture() + + assert {:ok, %BeaconMonitor{} = monitor} = + BeaconMonitors.create_monitor(user, %{"name" => "Shack Pi"}) + + assert monitor.user_id == user.id + assert monitor.name == "Shack Pi" + assert is_binary(monitor.token) + # strong_rand_bytes(32) |> url_encode64(padding: false) is 43 chars + assert String.length(monitor.token) == 43 + end + + test "generates a unique token per monitor" do + user = user_fixture() + {:ok, m1} = BeaconMonitors.create_monitor(user, %{"name" => "One"}) + {:ok, m2} = BeaconMonitors.create_monitor(user, %{"name" => "Two"}) + + refute m1.token == m2.token + end + + test "requires a name" do + user = user_fixture() + + assert {:error, changeset} = BeaconMonitors.create_monitor(user, %{"name" => ""}) + assert %{name: ["can't be blank"]} = errors_on(changeset) + end + end + + describe "list_monitors_for_user/1" do + test "returns monitors belonging to the user, newest first" do + user = user_fixture() + other = user_fixture() + + {:ok, first} = BeaconMonitors.create_monitor(user, %{"name" => "First"}) + Process.sleep(1100) + {:ok, second} = BeaconMonitors.create_monitor(user, %{"name" => "Second"}) + {:ok, _other} = BeaconMonitors.create_monitor(other, %{"name" => "Nope"}) + + assert [second_result, first_result] = BeaconMonitors.list_monitors_for_user(user) + assert second_result.id == second.id + assert first_result.id == first.id + end + + test "returns empty list when user has no monitors" do + user = user_fixture() + assert BeaconMonitors.list_monitors_for_user(user) == [] + end + end + + describe "delete_monitor/2" do + test "deletes a monitor owned by the user" do + user = user_fixture() + {:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Bye"}) + + assert {:ok, %BeaconMonitor{}} = BeaconMonitors.delete_monitor(user, monitor.id) + assert BeaconMonitors.list_monitors_for_user(user) == [] + end + + test "does not delete a monitor owned by another user" do + user = user_fixture() + other = user_fixture() + {:ok, monitor} = BeaconMonitors.create_monitor(other, %{"name" => "Theirs"}) + + assert {:error, :not_found} = BeaconMonitors.delete_monitor(user, monitor.id) + assert [_] = BeaconMonitors.list_monitors_for_user(other) + end + + test "returns :not_found for nonexistent id" do + user = user_fixture() + + assert {:error, :not_found} = + BeaconMonitors.delete_monitor(user, "11111111-1111-1111-1111-111111111111") + end + end + + describe "get_monitor_by_token/1" do + test "returns the monitor matching the token" do + user = user_fixture() + {:ok, monitor} = BeaconMonitors.create_monitor(user, %{"name" => "Find Me"}) + + found = BeaconMonitors.get_monitor_by_token(monitor.token) + assert found.id == monitor.id + end + + test "returns nil for unknown token" do + refute BeaconMonitors.get_monitor_by_token("not-a-real-token") + end + end +end diff --git a/test/microwaveprop_web/controllers/beacon_monitor_controller_test.exs b/test/microwaveprop_web/controllers/beacon_monitor_controller_test.exs new file mode 100644 index 00000000..238cac7e --- /dev/null +++ b/test/microwaveprop_web/controllers/beacon_monitor_controller_test.exs @@ -0,0 +1,65 @@ +defmodule MicrowavepropWeb.BeaconMonitorControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.BeaconMonitors + + 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 is_binary(monitor.token) + 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"}) + + conn = delete(conn, ~p"/users/beacon-monitors/#{monitor.id}") + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "deleted" + assert BeaconMonitors.list_monitors_for_user(user) == [] + end + + test "does not delete a monitor owned by another user", %{conn: conn} do + other = user_fixture() + {:ok, monitor} = BeaconMonitors.create_monitor(other, %{"name" => "Theirs"}) + + conn = delete(conn, ~p"/users/beacon-monitors/#{monitor.id}") + + assert redirected_to(conn) == ~p"/users/settings" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "not found" + assert [_] = BeaconMonitors.list_monitors_for_user(other) + end + end +end