towerops/test/towerops_web/live/dashboard_live_test.exs
Graham McIntire 3f08fd42b6 fix: handle alert_changed message in DashboardLive (#189)
Fixes FunctionClauseError when DashboardLive receives {:alert_changed, org_id}
message broadcast by user_auth hooks.

The user_auth module subscribes all LiveViews to the
"organization:#{org_id}:alerts" topic and broadcasts {:alert_changed, org_id}
messages when alerts are created or resolved. DashboardLive was missing a
handle_info/2 clause to handle this message format, causing crashes in
production.

Solution:
- Added handle_info/2 clause for {:alert_changed, _org_id} message
- Uses existing debounced reload pattern to update dashboard
- Added test to verify message is handled without crashing
- Updated both technical and user-facing changelogs

All tests pass. The fix follows the same pattern used in AlertLive.Index
and DeviceLive.Show.

Reviewed-on: graham/towerops-web#189
2026-03-27 13:26:39 -05:00

389 lines
10 KiB
Elixir

defmodule ToweropsWeb.DashboardLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Preseem.Insights
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization, site: site}
end
describe "empty state" do
test "shows onboarding when no devices exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Let's get started!"
assert html =~ "Create Your First Site"
end
test "requires authentication" do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/dashboard")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "health overview" do
test "displays health score", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Health"
assert html =~ "100"
end
test "displays device counts with up/down/unknown", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, d1} = create_device(organization, site, "Router 1", "192.168.1.1")
{:ok, d2} = create_device(organization, site, "Router 2", "192.168.1.2")
Towerops.Devices.update_device_status(d1, :up)
Towerops.Devices.update_device_status(d2, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Devices"
assert html =~ "Up"
assert html =~ "Down"
end
test "displays insight counts by urgency", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Device not polled"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Insights"
end
test "hides subscriber section when no Gaiia integration", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, _device} = create_device(organization, site)
{:ok, _view, html} = live(conn, ~p"/dashboard")
refute html =~ "Subscribers"
refute html =~ "MRR"
end
test "shows subscriber section when billing data exists", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, _device} = create_device(organization, site)
{:ok, integration} =
Towerops.Integrations.create_integration(organization.id, %{
provider: "gaiia",
enabled: true
})
{:ok, _} =
Towerops.Integrations.update_billing_totals(integration, 100, Decimal.new("5000.00"))
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Subs"
assert html =~ "100"
assert html =~ "5,000"
end
end
describe "alert feed" do
test "shows alerts with device links", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site, "Main Router", "10.0.0.1")
Towerops.Devices.update_device_status(device, :down)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device is unreachable"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Main Router"
assert html =~ "Device is unreachable"
assert html =~ ~p"/devices/#{device.id}"
end
test "shows acknowledge button for unacknowledged alerts", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "ACK"
end
end
describe "insight feed" do
test "shows insights from multiple sources", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Device not polled in 24h"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Device not polled in 24h"
assert html =~ "snmp"
end
test "dismiss insight removes it from feed", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, insight} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Poll gap detected"
})
{:ok, view, html} = live(conn, ~p"/dashboard")
assert html =~ "Poll gap detected"
view |> element("button[phx-value-id='#{insight.id}']") |> render_click()
html = render(view)
refute html =~ "Poll gap detected"
end
test "source filter filters insights via URL", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "SNMP insight"
})
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "reconciliation_finding",
source: "gaiia",
urgency: "info",
channel: "proactive",
title: "Gaiia insight",
dedup_key: "test_dedup",
metadata: %{"dedup_key" => "test_dedup"}
})
{:ok, view, _html} = live(conn, ~p"/dashboard?insight_source=snmp")
html = render(view)
assert html =~ "SNMP insight"
refute html =~ "Gaiia insight"
end
end
describe "site health grid" do
test "shows site cards with device counts", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Test Site"
assert html =~ ~p"/sites/#{site.id}"
end
test "shows subscriber data on site cards when Gaiia mapped", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-site",
name: "Tower",
account_count: 42,
total_mrr: Decimal.new("2100.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "42"
end
end
describe "real-time updates" do
@tag :skip
test "PubSub new alert event updates dashboard", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Create alert and broadcast
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device went down"
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{organization.id}:new",
{:new_alert, device.id, :device_down}
)
html = render(view)
assert html =~ "Device went down"
end
@tag :skip
test "PubSub alert resolved event updates dashboard", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "Device went down"
})
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Resolve the alert
Towerops.Alerts.resolve_alert(alert)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{organization.id}:resolved",
{:alert_resolved, device.id, :device_down}
)
html = render(view)
refute html =~ "Device went down"
end
test "handles alert_changed broadcasts without crashing", %{
conn: conn,
organization: organization
} do
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Broadcast alert_changed message (sent by user_auth hooks)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"organization:#{organization.id}:alerts",
{:alert_changed, organization.id}
)
# Should not crash and should remain connected
# Verify we can still render the page
html = render(view)
assert html =~ organization.name
end
end
defp create_device(organization, site, name \\ "Test Device", ip \\ "192.168.1.1") do
Towerops.Devices.create_device(%{
name: name,
ip_address: ip,
site_id: site.id,
organization_id: organization.id
})
end
end