towerops/test/towerops_web/live/device_live/index_test.exs
2026-06-15 11:15:46 -05:00

857 lines
25 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.IndexTest do
use ToweropsWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Towerops.Devices
alias Towerops.Monitoring.Check
alias ToweropsWeb.DeviceLive.Index
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
describe "index page rendering" do
test "renders empty state when no devices", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "No devices"
assert html =~ "New Device"
end
test "renders device list with devices", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Test Router"
assert html =~ "192.168.1.1"
end
test "renders site headers with device stats", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Test Site"
assert html =~ "1"
end
end
describe "real-time updates via PubSub" do
test "updates when a new device is created", %{conn: conn, site: site, organization: organization} do
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "No devices"
# Create a device (triggers PubSub broadcast)
{:ok, _device} =
Devices.create_device(%{
name: "New Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# LiveView should receive PubSub event and re-render
html = render(view)
assert html =~ "New Router"
assert html =~ "192.168.1.1"
end
test "updates when a device is modified", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Old Name",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Old Name"
{:ok, updated_device} = Devices.update_device(device, %{name: "New Name"})
# Verify the device was actually updated in the database
assert updated_device.name == "New Name"
# Verify LiveView received the PubSub message by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "New Name"
end
test "updates when a device status changes", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Status Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, _html} = live(conn, ~p"/devices")
{:ok, updated_device} = Devices.update_device_status(device, :up)
# Verify the status was actually updated
assert updated_device.status == :up
# Verify LiveView received the PubSub message by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "UP"
end
test "updates when a device is deleted", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Delete Me",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, html} = live(conn, ~p"/devices")
assert html =~ "Delete Me"
{:ok, _deleted} = Devices.delete_device(device)
html = render(view)
refute html =~ "Delete Me"
assert html =~ "No devices"
end
test "updates device quota after changes", %{conn: conn, site: site, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/devices")
{:ok, _device} =
Devices.create_device(%{
name: "Quota Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
html = render(view)
assert html =~ "1/10"
end
end
describe "tick timer" do
test "tick refreshes device stream on existing tab", %{conn: conn, site: site, organization: organization} do
{:ok, device} =
Devices.create_device(%{
name: "Tick Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
# Update device status directly in DB (no broadcast)
{:ok, updated_device} =
device
|> Ecto.Changeset.change(%{status: :up})
|> Towerops.Repo.update()
assert updated_device.status == :up
# Send tick to trigger reload
send(view.pid, :tick)
# Verify tick caused a reload by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "UP"
end
test "tick does not reload on discovered tab", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
# Send tick - should not crash or error
send(view.pid, :tick)
html = render(view)
assert html =~ "Discovered"
end
end
describe "site header enrichment" do
test "shows subscriber count on site header when Gaiia mapped", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-dev-list",
name: "Tower",
account_count: 55,
total_mrr: Decimal.new("2750.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "55"
end
test "hides subscriber count when no Gaiia mapping", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
refute html =~ "subscribers"
end
end
describe "discovered tab" do
test "renders discovered devices tab", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices?tab=discovered")
assert html =~ "Discovered"
end
test "renders discovered tab with pagination", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices?tab=discovered&page=1")
assert html =~ "Discovered"
end
end
describe "device management events" do
test "toggles reorder mode", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = view |> element("#toggle-reorder-mode") |> render_click()
assert html =~ "Reset"
end
test "resets device order", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
# Reset button only renders inside reorder mode — toggle in first.
view |> element("#toggle-reorder-mode") |> render_click()
html = view |> element("#reset-order") |> render_click()
assert html =~ "Order reset to alphabetical"
end
test "force_rediscover_all with no SNMP devices shows error", %{conn: conn, site: site, organization: organization} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: false
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = view |> element("#force-rediscover-all") |> render_click()
assert html =~ "No SNMP-enabled devices found"
end
test "reorder_device with invalid device_id shows error", %{
conn: conn,
site: site,
organization: organization
} do
# The #device-list hook target only renders when has_devices is true.
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
# Fired by the DeviceListReorder JS hook on drag-drop. Using render_hook
# validates the hook is wired to #device-list, even though it can't run JS.
html =
view
|> element("#device-list")
|> render_hook("reorder_device", %{
"device_id" => Ecto.UUID.generate(),
"new_position" => "1"
})
assert html =~ "not found"
end
test "reorder_site with invalid site_id shows error", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("#device-list")
|> render_hook("reorder_site", %{
"site_id" => Ecto.UUID.generate(),
"new_position" => "1"
})
assert html =~ "not found"
end
test "reorder_device succeeds for valid device", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, device_a} =
Devices.create_device(%{
name: "AAA",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _device_b} =
Devices.create_device(%{
name: "BBB",
ip_address: "10.0.0.2",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("#device-list")
|> render_hook("reorder_device", %{
"device_id" => device_a.id,
"new_position" => "2"
})
assert html =~ "Device order updated"
end
test "reorder_site succeeds for valid site", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("#device-list")
|> render_hook("reorder_site", %{
"site_id" => site.id,
"new_position" => "1"
})
assert html =~ "Site order updated"
end
test "force_rediscover_all enqueues discovery for SNMP-enabled devices", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "SNMP Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
{:ok, view, _html} = live(conn, ~p"/devices")
html = view |> element("#force-rediscover-all") |> render_click()
assert html =~ "Discovery started"
end
test "add_discovered_device with invalid JSON identifier shows error flash", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
html = render_hook(view, "add_discovered_device", %{"identifier" => "not-json"})
assert html =~ "Invalid device identifier"
end
end
describe "health and response time helpers" do
test "renders device with healthy passing check (worst_status=0)", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, device} =
Devices.create_device(%{
name: "Healthy",
ip_address: "10.7.0.1",
site_id: site.id,
organization_id: organization.id
})
# Set every check on this device to state=0 (auto ping check defaults to state=3)
organization.id
|> Towerops.Monitoring.list_checks()
|> Enum.filter(&(&1.device_id == device.id))
|> Enum.each(fn c ->
c
|> Check.state_changeset(%{
current_state: 0,
last_check_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Towerops.Repo.update!()
end)
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Healthy"
# health_dot_color worst_status=0 path
assert html =~ "bg-green-500"
end
test "renders device with critical check (worst_status=2)", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, device} =
Devices.create_device(%{
name: "Critical",
ip_address: "10.7.0.2",
site_id: site.id,
organization_id: organization.id
})
# Update *all* checks for this device to state=2 (creating a device auto-creates a ping check
# with state=3 Unknown; max(c.current_state) over both must be 2 to land in the red branch).
organization.id
|> Towerops.Monitoring.list_checks()
|> Enum.filter(&(&1.device_id == device.id))
|> Enum.each(fn c ->
c
|> Check.state_changeset(%{
current_state: 2,
last_check_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Towerops.Repo.update!()
end)
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Critical"
# Red dot for worst_status=2
assert html =~ "bg-red-500"
end
end
describe "search and status filter events" do
setup %{site: site, organization: organization} do
{:ok, up_device} =
Devices.create_device(%{
name: "Alpha",
ip_address: "10.1.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, up_device} = Devices.update_device_status(up_device, :up)
{:ok, down_device} =
Devices.create_device(%{
name: "Bravo",
ip_address: "10.2.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, down_device} = Devices.update_device_status(down_device, :down)
%{up_device: up_device, down_device: down_device}
end
test "search event does not crash and updates query state", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
# The device-list table uses phx-update="ignore", so we can't observe
# filtered rows in the rendered HTML — but we can verify the search input
# value reflects the new query and that no crash happens.
html =
view
|> form("form[phx-change=search]", %{search_query: "Alpha"})
|> render_change()
assert html =~ ~s(value="Alpha")
end
test "search with empty query does not crash", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> form("form[phx-change=search]", %{search_query: ""})
|> render_change()
# Page still renders normally with both devices
assert html =~ "Alpha"
assert html =~ "Bravo"
end
test "filter_status up sets the filter and renders without crashing", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("button[phx-click=filter_status][phx-value-status=up]")
|> render_click()
# The clear-filter X icon only renders when status_filter != "all",
# so its presence proves the filter state changed.
assert html =~ "Clear filter"
end
test "filter_status down sets the filter", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("button[phx-click=filter_status][phx-value-status=down]")
|> render_click()
assert html =~ "Clear filter"
end
test "clicking the same filter twice toggles back to all", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
view
|> element("button[phx-click=filter_status][phx-value-status=up]")
|> render_click()
html =
view
|> element("button[phx-click=filter_status][phx-value-status=up]")
|> render_click()
# When status_filter is "all" again, the "Clear filter" X is not shown
refute html =~ "Clear filter"
end
end
describe "add_discovered_device event" do
test "shows error flash on invalid JSON identifier", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
# Drive the event directly through the LV process — the corresponding
# button only renders when there are discovered devices.
html = render_hook(view, "add_discovered_device", %{"identifier" => "not-json{"})
assert html =~ "Invalid device identifier"
end
end
describe "reorder_device / reorder_site unauthorized branch" do
test "reorder_device flashes 'no access' for cross-org device", %{
conn: conn,
site: site,
organization: organization,
user: user
} do
_ = user
# Need at least one device on this org so the device-list element renders
{:ok, _own_device} =
Devices.create_device(%{
name: "OwnDevice",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
# Cross-org device that exists but belongs elsewhere
cross_user = Towerops.AccountsFixtures.user_fixture()
cross_org = Towerops.OrganizationsFixtures.organization_fixture(cross_user.id)
{:ok, cross_site} =
Towerops.Sites.create_site(%{name: "Cross", organization_id: cross_org.id})
{:ok, cross_device} =
Devices.create_device(%{
name: "Cross",
ip_address: "10.99.0.1",
site_id: cross_site.id,
organization_id: cross_org.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("#device-list")
|> render_hook("reorder_device", %{
"device_id" => cross_device.id,
"new_position" => "1"
})
assert html =~ ~r/don't have access|don't have access/
end
test "reorder_site flashes 'no access' for cross-org site", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _own_device} =
Devices.create_device(%{
name: "OwnDevice2",
ip_address: "10.0.0.2",
site_id: site.id,
organization_id: organization.id
})
cross_user = Towerops.AccountsFixtures.user_fixture()
cross_org = Towerops.OrganizationsFixtures.organization_fixture(cross_user.id)
{:ok, cross_site} =
Towerops.Sites.create_site(%{name: "Cross", organization_id: cross_org.id})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> element("#device-list")
|> render_hook("reorder_site", %{
"site_id" => cross_site.id,
"new_position" => "1"
})
assert html =~ ~r/don't have access|don't have access/
end
end
describe "handle_info catch-all" do
test "ignores unrelated messages", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
send(view.pid, {:some_unknown_event, "data"})
assert render(view)
end
test "device_status_changed on discovered tab does not trigger reload", %{
conn: conn,
organization: organization
} do
{:ok, view, _html} = live(conn, ~p"/devices?tab=discovered")
send(view.pid, {:device_status_changed, organization.id})
# The view should still render without crashing
assert render(view)
end
end
describe "pure helpers" do
test "filter_by_search returns all when query is empty/nil" do
devices = [%{name: "Alpha", ip_address: "10.0.0.1"}, %{name: "Beta", ip_address: "10.0.0.2"}]
assert Index.filter_by_search(devices, "") == devices
assert Index.filter_by_search(devices, nil) == devices
end
test "filter_by_search matches by name (case-insensitive)" do
devices = [
%{name: "Alpha Router", ip_address: "10.0.0.1"},
%{name: "Beta Switch", ip_address: "10.0.0.2"}
]
result = Index.filter_by_search(devices, "alpha")
assert length(result) == 1
assert hd(result).name == "Alpha Router"
end
test "filter_by_search matches by ip address" do
devices = [
%{name: "Foo", ip_address: "10.0.0.1"},
%{name: "Bar", ip_address: "192.168.1.5"}
]
result = Index.filter_by_search(devices, "192.168")
assert length(result) == 1
assert hd(result).name == "Bar"
end
test "filter_by_search handles nil device name without crashing" do
devices = [%{name: nil, ip_address: "10.0.0.1"}]
assert Index.filter_by_search(devices, "anything") == []
end
test "filter_by_status returns appropriate subsets" do
devices = [
%{status: :up},
%{status: :down},
%{status: :unknown}
]
assert Index.filter_by_status(devices, "all") == devices
assert Index.filter_by_status(devices, "up") == [%{status: :up}]
assert Index.filter_by_status(devices, "down") == [%{status: :down}]
assert Index.filter_by_status(devices, "unknown") == [%{status: :unknown}]
# Unknown filter strings fall through to "all"
assert Index.filter_by_status(devices, "garbage") == devices
end
test "calculate_site_stats counts statuses" do
devices = [
%{status: :up},
%{status: :up},
%{status: :down},
%{status: :unknown}
]
stats = Index.calculate_site_stats(devices)
assert stats.total == 4
assert stats.up == 2
assert stats.down == 1
assert stats.unknown == 1
end
test "calculate_site_stats handles empty list" do
assert %{total: 0, up: 0, down: 0, unknown: 0} = Index.calculate_site_stats([])
end
test "device_type_label translates known device roles" do
assert Index.device_type_label(nil) == "Unknown"
assert Index.device_type_label("access_point") == "Access Point"
assert Index.device_type_label("router") == "Router"
assert Index.device_type_label("switch") == "Switch"
assert Index.device_type_label(123) == "Unknown"
end
end
describe "search filter applied to live view" do
test "search by ip address filter still re-renders without crashing", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "ByIp",
ip_address: "10.5.5.5",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
view
|> form("form[phx-change=search]", %{search_query: "10.5.5.5"})
|> render_change()
assert html =~ ~s(value="10.5.5.5")
end
test "filter_status unknown via render_hook sets the filter", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices")
# The "unknown" button only renders when there are unknown-status devices,
# so drive the event directly through the LV process.
html = render_hook(view, "filter_status", %{"status" => "unknown"})
# Page should still render normally without crashing
assert is_binary(html) and byte_size(html) > 0
end
end
describe "reset_order with no devices" do
test "still succeeds and shows flash", %{conn: conn, organization: organization} do
# Create a device, render, toggle reorder, reset; then delete all and toggle
# confirm graceful path with empty device list afterwards
{:ok, device} =
Devices.create_device(%{
name: "TempForReset",
ip_address: "10.0.0.99",
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
view |> element("#toggle-reorder-mode") |> render_click()
html = view |> element("#reset-order") |> render_click()
assert html =~ "Order reset to alphabetical"
_ = device
end
end
end