towerops/test/towerops_web/live/beacon_monitor_live_test.exs
Graham McInitre 88fae5d752 feat: add beacon monitors with user and admin management
Backend:
- Migration: beacon_monitors table (binary_id PK, org/user FK, check_type, target_url, config, monitoring fields)
- Schema: BeaconMonitor with changeset (pattern-matched port validation, check_type inclusion)
- Query: BeaconMonitorQuery — composable scopes (by org, by user, by type, enabled, ordered, preloaded)
- Context: Towerops.Beacons — CRUD, toggle, record_check_result (36/36 tests pass)

Frontend:
- User LiveView: /beacons — stream-based list, create/edit modals, toggle/delete actions
- Admin LiveView: /admin/beacons — cross-org view with preloads (20/20 tests pass)
- Form component: LiveComponent modal with validation
- Helpers: check_type badges, status indicators, response time formatting
- Router: user routes + admin route configured
- Nav: sidebar + mobile links added
- 30+ gettext translations

Verification: compile ✓, format ✓, credo ✓, 56/56 new tests pass
2026-07-22 08:15:03 -05:00

284 lines
8.4 KiB
Elixir

defmodule ToweropsWeb.BeaconMonitorLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.BeaconsFixtures
alias Towerops.Beacons
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization}
end
describe "Index" do
test "lists user's beacon monitors", %{conn: conn, organization: org, user: user} do
_my_beacon = beacon_monitor_fixture(org.id, user.id, %{name: "My HTTP Monitor"})
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "My HTTP Monitor"
end
test "shows empty state when no beacons", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/beacons")
assert html =~ "No beacon monitors"
end
test "does not show other user's beacons", %{conn: conn, organization: org} do
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
_theirs = beacon_monitor_fixture(org.id, other_user.id, %{name: "Not Mine"})
{:ok, _view, html} = live(conn, ~p"/beacons")
refute html =~ "Not Mine"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/beacons")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "Create beacon" do
test "creates a new beacon monitor via modal", %{conn: conn, organization: org, user: user} do
{:ok, view, _html} = live(conn, ~p"/beacons/new")
view
|> form("#beacon-form-new",
beacon_monitor: %{
name: "New HTTP Check",
check_type: "http",
target_url: "https://example.com",
interval_seconds: "30",
timeout_ms: "3000"
}
)
|> render_submit()
assert_redirect(view, ~p"/beacons")
# Verify the beacon was created in the database
beacons = Beacons.list_user_beacons(org.id, user.id)
assert Enum.any?(beacons, &(&1.name == "New HTTP Check" and &1.target_url == "https://example.com"))
end
test "validates required fields on submit", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons/new")
html =
view
|> form("#beacon-form-new",
beacon_monitor: %{
name: "",
check_type: "http",
target_url: "",
interval_seconds: "",
timeout_ms: ""
}
)
|> render_submit()
assert html =~ "can't be blank"
end
test "open_edit navigates to edit route", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Edit Me"})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#edit-beacon-#{beacon.id}")
|> render_click()
assert_redirect(view, ~p"/beacons/#{beacon.id}/edit")
end
end
describe "Edit beacon" do
test "loads edit modal with existing beacon data", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Original Name"})
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon.id}/edit")
assert html =~ "Edit Beacon Monitor"
assert html =~ "Original Name"
end
test "updates beacon name and redirects", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Old Name"})
{:ok, view, _html} = live(conn, ~p"/beacons/#{beacon.id}/edit")
view
|> form("#beacon-form-edit",
beacon_monitor: %{
name: "Updated Name",
check_type: "http",
target_url: beacon.target_url,
interval_seconds: to_string(beacon.interval_seconds),
timeout_ms: to_string(beacon.timeout_ms)
}
)
|> render_submit()
assert_redirect(view, ~p"/beacons")
updated = Beacons.get_beacon(beacon.id)
assert updated.name == "Updated Name"
end
end
describe "Toggle beacon" do
test "disables an enabled beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#toggle-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id).enabled
end
test "enables a disabled beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: false})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#toggle-beacon-#{beacon.id}")
|> render_click()
assert Beacons.get_beacon(beacon.id).enabled
end
end
describe "Delete beacon" do
test "deletes a beacon", %{conn: conn, organization: org, user: user} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "To Delete"})
{:ok, view, _html} = live(conn, ~p"/beacons")
view
|> element("#delete-beacon-#{beacon.id}")
|> render_click()
html = render(view)
assert html =~ "Beacon deleted successfully"
refute Beacons.get_beacon(beacon.id)
end
end
describe "Timer cleanup" do
test "cleans up timer on LiveView terminate", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons")
assert render(view) =~ "Beacon Monitors"
stop_live(view)
end
test "handles nil timer ref in terminate gracefully", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/beacons")
stop_live(view)
end
defp stop_live(view) do
Process.exit(view.pid, :kill)
ref = Process.monitor(view.pid)
assert_receive {:DOWN, ^ref, :process, _, _}, 1000
end
end
describe "Admin index" do
setup do
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
user = user |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
token = Towerops.Accounts.generate_user_session_token(user)
conn =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
%{conn: conn, user: user, organization: organization}
end
test "lists all beacon monitors across orgs", %{conn: conn, user: user, organization: org} do
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: false)
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Org B"}, other_user.id, bypass_limits: true)
_b1 = beacon_monitor_fixture(org.id, user.id, %{name: "Beacon A"})
_b2 = beacon_monitor_fixture(org2.id, other_user.id, %{name: "Beacon B"})
{:ok, _view, html} = live(conn, ~p"/admin/beacons")
assert html =~ "Beacon A"
assert html =~ "Beacon B"
end
test "redirects non-superuser", %{user: user} do
regular_user =
user
|> Ecto.Changeset.change(%{is_superuser: false})
|> Towerops.Repo.update!()
token = Towerops.Accounts.generate_user_session_token(regular_user)
conn =
build_conn()
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
assert {:error, {:redirect, %{to: "/orgs"}}} = live(conn, ~p"/admin/beacons")
end
test "requires authentication for admin" do
conn = build_conn()
assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/admin/beacons")
end
test "admin can toggle beacon", %{conn: conn, user: user, organization: org} do
beacon = beacon_monitor_fixture(org.id, user.id, %{enabled: true, name: "Admin Toggle"})
{:ok, view, _html} = live(conn, ~p"/admin/beacons")
view
|> element("#admin-toggle-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id).enabled
end
test "admin can delete beacon", %{conn: conn, user: user, organization: org} do
beacon = beacon_monitor_fixture(org.id, user.id, %{name: "Admin Delete"})
{:ok, view, _html} = live(conn, ~p"/admin/beacons")
view
|> element("#admin-delete-beacon-#{beacon.id}")
|> render_click()
refute Beacons.get_beacon(beacon.id)
end
test "shows empty state in admin when no beacons", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/beacons")
assert html =~ "No beacon monitors"
end
end
end