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
284 lines
8.4 KiB
Elixir
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
|