From 332ee37706c2acf8f3926f235e42b3ce47678338 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 8 May 2026 18:12:32 -0500 Subject: [PATCH] test: GraphLive live mode + Show snmp_sensor format + Alert/Activity events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- .../live/activity_feed_live_test.exs | 34 +++ .../live/alert_live_events_test.exs | 63 ++++++ .../live/device_live/show_helpers_test.exs | 51 +++++ .../live/graph_live/show_live_mode_test.exs | 209 ++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 test/towerops_web/live/graph_live/show_live_mode_test.exs diff --git a/test/towerops_web/live/activity_feed_live_test.exs b/test/towerops_web/live/activity_feed_live_test.exs index 08313bda..d3f768cf 100644 --- a/test/towerops_web/live/activity_feed_live_test.exs +++ b/test/towerops_web/live/activity_feed_live_test.exs @@ -50,6 +50,40 @@ defmodule ToweropsWeb.ActivityFeedLiveTest do _ = render_hook(view, "search", %{"search" => "router"}) assert render(view) =~ "Activity" 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 describe "severity / type helpers" do diff --git a/test/towerops_web/live/alert_live_events_test.exs b/test/towerops_web/live/alert_live_events_test.exs index 52d7feca..0681aa96 100644 --- a/test/towerops_web/live/alert_live_events_test.exs +++ b/test/towerops_web/live/alert_live_events_test.exs @@ -43,6 +43,69 @@ defmodule ToweropsWeb.AlertLive.IndexEventsTest do %{organization: organization, site: site, device: device, alert1: alert1, alert2: alert2} 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 test "expands and collapses an alert row", %{conn: conn, alert1: alert1} do {:ok, view, _html} = live(conn, ~p"/alerts") diff --git a/test/towerops_web/live/device_live/show_helpers_test.exs b/test/towerops_web/live/device_live/show_helpers_test.exs index c8f3c829..6069f96b 100644 --- a/test/towerops_web/live/device_live/show_helpers_test.exs +++ b/test/towerops_web/live/device_live/show_helpers_test.exs @@ -121,5 +121,56 @@ defmodule ToweropsWeb.DeviceLive.ShowHelpersTest do test "unknown check_type rounds to 2 decimals" do assert "42.5" == Show.format_check_value(%{value: 42.5}, %{check_type: "weird"}) 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 diff --git a/test/towerops_web/live/graph_live/show_live_mode_test.exs b/test/towerops_web/live/graph_live/show_live_mode_test.exs new file mode 100644 index 00000000..0a040c10 --- /dev/null +++ b/test/towerops_web/live/graph_live/show_live_mode_test.exs @@ -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