test: GraphLive live mode + Show snmp_sensor format + Alert/Activity events

Bumps coverage by exercising several previously-uncovered branches:

- GraphLive.Show: new test file driving `range=live` for every
  sensor_type (latency, processors, memory, traffic, temperature,
  storage, voltage, count, pppoe_sessions, connections, wireless,
  unknown), plus `get_title_suffix/1` across sensor_id / interface_id
  / storage_id, and `change_range` flipping into live mode.
- DeviceLive.Show: format_check_value/2 now covers the snmp_sensor
  branch across temperature, voltage, current, power, frequency,
  humidity, fanspeed (default + custom unit), and an unknown
  sensor_type with/without a configured unit.
- ActivityFeedLive: clear_search and load_more events; PubSub
  handle_info clauses for new_alert / alert_resolved / device_added
  / config_changed / sync_completed / device_event / :tick / catch-all.
- AlertLive.Index: acknowledge / resolve event handlers across
  success, not-found, and cross-org unauthorized branches.

Coverage: 86.42% → 86.52%.
This commit is contained in:
Graham McIntire 2026-05-08 18:12:32 -05:00
parent fc20d60838
commit 332ee37706
4 changed files with 357 additions and 0 deletions

View file

@ -50,6 +50,40 @@ defmodule ToweropsWeb.ActivityFeedLiveTest do
_ = render_hook(view, "search", %{"search" => "router"}) _ = render_hook(view, "search", %{"search" => "router"})
assert render(view) =~ "Activity" assert render(view) =~ "Activity"
end end
test "clear_search resets the search box", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/activity")
_ = render_hook(view, "search", %{"search" => "anything"})
_ = render_hook(view, "clear_search", %{})
assert render(view) =~ "Activity"
end
test "load_more bumps the limit and re-loads", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/activity")
_ = render_hook(view, "load_more", %{})
assert render(view) =~ "Activity"
end
end
describe "PubSub broadcasts trigger reloads" do
test "new_alert / alert_resolved / device_added / config_changed / sync_completed / device_event / tick all keep the view alive",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/activity")
pid = view.pid
send(pid, {:new_alert, "device-1", "device_down"})
send(pid, {:alert_resolved, "device-1", "device_down"})
send(pid, {:device_added, "device-2"})
send(pid, {:config_changed, "device-1"})
send(pid, {:sync_completed, :ok})
send(pid, {:device_event, "device-1", "interface_down"})
send(pid, :tick)
send(pid, {:unknown_message, :ignored})
# Force a render after the messages have been processed.
assert render(view) =~ "Activity"
end
end end
describe "severity / type helpers" do describe "severity / type helpers" do

View file

@ -43,6 +43,69 @@ defmodule ToweropsWeb.AlertLive.IndexEventsTest do
%{organization: organization, site: site, device: device, alert1: alert1, alert2: alert2} %{organization: organization, site: site, device: device, alert1: alert1, alert2: alert2}
end end
describe "acknowledge / resolve event handlers" do
test "acknowledge flashes success and stamps the alert", %{
conn: conn,
alert1: alert1
} do
{:ok, view, _html} = live(conn, ~p"/alerts")
html = render_hook(view, "acknowledge", %{"id" => alert1.id})
assert html =~ "acknowledged" or html =~ "Alert"
assert Alerts.get_alert!(alert1.id).acknowledged_at
end
test "acknowledge with unknown id flashes 'not found'", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/alerts")
html = render_hook(view, "acknowledge", %{"id" => Ecto.UUID.generate()})
assert html =~ "not found" or html =~ "Alert"
end
test "resolve flashes success and resolves the alert", %{
conn: conn,
alert1: alert1
} do
{:ok, view, _html} = live(conn, ~p"/alerts")
_ = render_hook(view, "resolve", %{"id" => alert1.id})
assert Alerts.get_alert!(alert1.id).resolved_at
end
test "resolve with unknown id flashes 'not found'", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/alerts")
html = render_hook(view, "resolve", %{"id" => Ecto.UUID.generate()})
assert html =~ "not found" or html =~ "Alert"
end
test "acknowledge of an alert from another org is rejected as unauthorized", %{conn: conn} do
# Create a foreign alert outside the user's org.
foreign_user = Towerops.AccountsFixtures.user_fixture()
foreign_org = Towerops.OrganizationsFixtures.organization_fixture(foreign_user.id)
{:ok, foreign_site} = Towerops.Sites.create_site(%{name: "FS", organization_id: foreign_org.id})
{:ok, foreign_device} =
Towerops.Devices.create_device(%{
name: "Foreign",
ip_address: "10.99.99.99",
site_id: foreign_site.id,
organization_id: foreign_org.id
})
{:ok, foreign_alert} =
Alerts.create_alert(%{
device_id: foreign_device.id,
alert_type: "device_down",
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
severity: 1
})
{:ok, view, _html} = live(conn, ~p"/alerts")
html = render_hook(view, "acknowledge", %{"id" => foreign_alert.id})
assert html =~ "access" or html =~ "not found" or html =~ "Alert"
end
end
describe "toggle_alert" do describe "toggle_alert" do
test "expands and collapses an alert row", %{conn: conn, alert1: alert1} do test "expands and collapses an alert row", %{conn: conn, alert1: alert1} do
{:ok, view, _html} = live(conn, ~p"/alerts") {:ok, view, _html} = live(conn, ~p"/alerts")

View file

@ -121,5 +121,56 @@ defmodule ToweropsWeb.DeviceLive.ShowHelpersTest do
test "unknown check_type rounds to 2 decimals" do test "unknown check_type rounds to 2 decimals" do
assert "42.5" == Show.format_check_value(%{value: 42.5}, %{check_type: "weird"}) assert "42.5" == Show.format_check_value(%{value: 42.5}, %{check_type: "weird"})
end end
test "snmp_sensor with temperature uses 1 decimal + °C" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "temperature"}}
assert "23.5°C" == Show.format_check_value(%{value: 23.456}, check)
end
test "snmp_sensor with voltage uses 2 decimals + V" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "voltage"}}
assert "12.34V" == Show.format_check_value(%{value: 12.341}, check)
end
test "snmp_sensor with current uses 2 decimals + A" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "current"}}
result = Show.format_check_value(%{value: 1.523}, check)
assert result == "1.52A"
end
test "snmp_sensor with power uses 1 decimal + W" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "power"}}
assert "75.5W" == Show.format_check_value(%{value: 75.456}, check)
end
test "snmp_sensor with frequency uses 0 decimals + Hz" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "frequency"}}
assert "60.0Hz" == Show.format_check_value(%{value: 60.0}, check)
end
test "snmp_sensor with humidity uses 1 decimal + %" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "humidity"}}
assert "55.5%" == Show.format_check_value(%{value: 55.55}, check)
end
test "snmp_sensor with fanspeed uses RPM by default" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "fanspeed", "sensor_unit" => nil}}
assert "3000 RPM" == Show.format_check_value(%{value: 3000}, check)
end
test "snmp_sensor with fanspeed honors a configured unit" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "fanspeed", "sensor_unit" => "rpm"}}
assert "3000rpm" == Show.format_check_value(%{value: 3000}, check)
end
test "snmp_sensor with unknown sensor_type uses default 2-decimal precision and the configured unit" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "lux", "sensor_unit" => "lx"}}
assert "123.46lx" == Show.format_check_value(%{value: 123.456}, check)
end
test "snmp_sensor with unknown sensor_type and no unit defaults to no unit suffix" do
check = %{check_type: "snmp_sensor", config: %{"sensor_type" => "lux"}}
assert "123.46" == Show.format_check_value(%{value: 123.456}, check)
end
end end
end end

View file

@ -0,0 +1,209 @@
defmodule ToweropsWeb.GraphLive.ShowLiveModeTest do
@moduledoc """
Coverage for the `range=live` (live polling) entry path of GraphLive.Show.
Drives `load_live_mode_graph_data/1` and `get_title_suffix/1` across the
sensor / interface / storage suffix branches.
We don't actually poll SNMP — the live test just needs to mount, which
exercises the live-mode setup branches (`is_live_mode == true`) plus the
per-sensor-type chart_config lookups.
"""
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Organizations
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.Storage
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Organizations.create_organization(%{name: "Live Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "Live Site", organization_id: organization.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Live Router",
ip_address: "10.50.0.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "live-router"})
|> Repo.insert!()
%{organization: organization, site: site, device: device, snmp_device: snmp_device}
end
describe "live mode entry (range=live)" do
test "renders live processors graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/processors?range=live")
assert html =~ "Processor Usage"
assert html =~ "Live"
end
test "renders live memory graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/memory?range=live")
assert html =~ "Memory Usage"
assert html =~ "Live"
end
test "renders live latency graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/latency?range=live")
assert html =~ "Ping Latency"
assert html =~ "Live"
end
test "renders live traffic graph (overall device)", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/traffic?range=live")
assert html =~ "Traffic"
assert html =~ "Live"
end
test "renders live temperature graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/temperature?range=live")
assert html =~ "Temperature"
assert html =~ "Live"
end
test "renders live storage graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/storage?range=live")
assert html =~ "Storage"
assert html =~ "Live"
end
test "renders live wireless graph (single-token branch in get_chart_config)", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/wireless?range=live")
assert html =~ "Wireless"
assert html =~ "Live"
end
test "renders live count graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/count?range=live")
assert html =~ "Count"
assert html =~ "Live"
end
test "renders live pppoe_sessions graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/pppoe_sessions?range=live")
assert html =~ "PPPoE"
assert html =~ "Live"
end
test "renders live connections graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/connections?range=live")
assert html =~ "Connections"
assert html =~ "Live"
end
test "renders live voltage graph", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/voltage?range=live")
assert html =~ "Voltage"
assert html =~ "Live"
end
test "renders live unknown sensor type with humanized title", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/some_custom_metric?range=live")
# humanize_sensor_type/1 turns "some_custom_metric" → "Some Custom Metric"
assert html =~ "Some Custom Metric"
assert html =~ "Live"
end
end
describe "live mode title suffixes (covers get_title_suffix/1 branches)" do
test "live mode with sensor_id appends sensor description to title", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "voltage",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.4.1",
sensor_descr: "PSU-1 Voltage",
sensor_unit: "V"
})
|> Repo.insert!()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/voltage?range=live&sensor_id=#{sensor.id}")
assert html =~ "PSU-1 Voltage"
assert html =~ "Live"
end
test "live mode with interface_id appends interface name (traffic)", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
iface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "ether1",
if_phys_address: "aa:bb:cc:00:00:01"
})
|> Repo.insert!()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/traffic?range=live&interface_id=#{iface.id}")
assert html =~ "ether1"
assert html =~ "Live"
end
test "live mode with storage_id appends storage description (storage_volume)", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: "1",
storage_oid: "1.3.6.1.2.1.25.2.3.1.1",
description: "/data",
storage_type: "fixed_disk"
})
|> Repo.insert!()
{:ok, _view, html} =
live(
conn,
~p"/devices/#{device.id}/graph/storage_volume?range=live&storage_id=#{storage.id}"
)
assert html =~ "/data"
assert html =~ "Live"
end
end
describe "change_range to live" do
test "switching to range=live patches the URL and flips into live mode", %{
conn: conn,
device: device
} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/processors?range=24h")
assert render_click(view, "change_range", %{"range" => "live"}) =~ "Live"
end
end
# Silence unused-alias warning for Snmp.
_ = Snmp
end