towerops/test/towerops_web/live/device_live/show_test.exs
Graham McIntire 1c222f8ff5
feat: add capacity insight worker, UI, and reporting views
Add CapacityInsightWorker (every 15 min) that generates critical/warning
insights when backhaul utilization exceeds 90%/75% and auto-resolves
when it drops below 70%.

Add capacity and utilization columns to the device ports tab with
set/clear capacity controls. Add organization-level /capacity page
with summary cards and per-site capacity table. Add capacity summary
card to site show page. Add Capacity link to nav menu.
2026-03-07 14:42:40 -06:00

381 lines
13 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.ShowTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
setup do
user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
device =
Towerops.DevicesFixtures.device_fixture(%{
organization_id: organization.id,
name: "Test Router",
ip_address: "192.168.1.1"
})
%{user: user, organization: organization, device: device}
end
describe "authenticated access" do
test "redirects to login when not authenticated", %{conn: conn, device: device} do
assert {:error, redirect} = live(conn, ~p"/devices/#{device.id}?tab=overview")
assert {:redirect, %{to: to}} = redirect
assert to =~ "/users/log-in"
end
end
describe "mount and display" do
test "displays device name and IP address", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
assert html =~ "Test Router"
assert html =~ "192.168.1.1"
end
test "redirects to add tab=overview when no tab param", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
# Without a tab param, the LiveView does push_patch to add ?tab=overview
assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/devices/#{device.id}")
assert to =~ "tab=overview"
end
test "sets page title to device name", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
assert page_title(view) =~ "Test Router"
end
end
describe "checks tab" do
test "displays checks tab link", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
assert html =~ "Checks"
end
test "loads checks tab with empty state", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=checks")
assert html =~ "No checks configured"
assert html =~ "Add Check"
end
end
describe "tab navigation" do
test "renders overview tab with device information", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
assert html =~ "Device Information"
end
test "switches to neighbors tab via patch", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
# Clicking the Neighbors tab link triggers a patch (not a redirect)
html = view |> element("a", "Neighbors") |> render_click()
assert html =~ "Neighbors"
end
test "renders logs tab directly", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=logs")
assert html =~ "Test Router"
end
end
describe "organization access control" do
test "denies access to device from another organization", %{conn: conn, device: device} do
other_user = Towerops.AccountsFixtures.user_fixture(enable_totp: true)
{:ok, _other_org} =
Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
conn = log_in_user(conn, other_user)
assert {:error, {:live_redirect, %{to: "/devices", flash: flash}}} =
live(conn, ~p"/devices/#{device.id}?tab=overview")
assert flash["error"] =~ "You don't have access to this device"
end
test "redirects with not found for nonexistent device", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
fake_id = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: "/devices", flash: flash}}} =
live(conn, ~p"/devices/#{fake_id}?tab=overview")
assert flash["error"] =~ "Device not found"
end
end
describe "tab rendering" do
test "renders interfaces tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=interfaces")
assert html =~ "Test Router"
end
test "renders sensors tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=sensors")
assert html =~ "Test Router"
end
test "renders events tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=events")
assert html =~ "Test Router"
end
end
describe "handle_info events" do
test "handles device_status_changed", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:device_status_changed, device.id, :up, 10})
assert render(view)
end
test "handles discovery_completed", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:discovery_completed, device.id})
html = render(view)
assert html =~ "Discovery completed"
end
test "handles sensors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:sensors_updated, device.id})
assert render(view)
end
test "handles interfaces_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:interfaces_updated, device.id})
assert render(view)
end
test "handles neighbors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:neighbors_updated, device.id})
assert render(view)
end
test "handles arp_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:arp_updated, device.id})
assert render(view)
end
test "handles mac_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:mac_updated, device.id})
assert render(view)
end
test "handles state_sensors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:state_sensors_updated, device.id})
assert render(view)
end
test "handles processors_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:processors_updated, device.id})
assert render(view)
end
test "handles storage_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:storage_updated, device.id})
assert render(view)
end
test "handles monitoring_check_updated", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:monitoring_check_updated, device.id})
assert render(view)
end
test "handles device_event", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:device_event, %{device_id: device.id, type: "test"}})
assert render(view)
end
test "handles refresh_data", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, :refresh_data)
assert render(view)
end
test "handles agents_stale with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agents_stale, []})
assert render(view)
end
test "handles agent_connected with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_connected, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
test "handles agent_disconnected with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_disconnected, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
test "handles agent_heartbeat with unrelated agent", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=overview")
send(view.pid, {:agent_heartbeat, Ecto.UUID.generate(), Ecto.UUID.generate()})
assert render(view)
end
end
describe "ports tab capacity" do
setup %{device: device} do
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "radio", sys_descr: "PTP"})
|> Towerops.Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_speed: 1_000_000_000,
if_type: 6,
configured_capacity_bps: 300_000_000,
capacity_source: "manual"
})
|> Towerops.Repo.insert!()
# Insert stats for utilization calculation
now = DateTime.utc_now()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
|> Towerops.Repo.insert!()
# ~150 Mbps = 50% of 300 Mbps capacity
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 1_125_000_000,
checked_at: DateTime.add(now, -60, :second)
})
|> Towerops.Repo.insert!()
%{snmp_device: snmp_device, interface: interface}
end
test "displays capacity column on ports tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
assert html =~ "Capacity"
assert html =~ "300.0 Mbps"
end
test "displays utilization bar on ports tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
assert html =~ "Utilization"
end
test "set capacity via event handler", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html =
render_click(view, "set_capacity", %{
"interface_id" => interface.id,
"capacity_mbps" => "500"
})
assert html =~ "500.0 Mbps"
end
test "clear capacity via event handler", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html = render_click(view, "clear_capacity", %{"interface_id" => interface.id})
# After clearing, the capacity badge should not show 300 Mbps anymore
refute html =~ "300.0 Mbps"
end
end
end