From f99c041e84e640cd6e3b00271be555f6dcfe7f26 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 5 May 2026 11:13:01 -0500 Subject: [PATCH] test: cover live view event handlers, invitation query, fix flaky timeout test --- .../organizations/invitation_query_test.exs | 95 ++++++++++++++ .../towerops/snmp/deferred_discovery_test.exs | 4 +- .../live/alert_live_events_test.exs | 118 ++++++++++++++++++ .../live/insights_live_events_test.exs | 102 +++++++++++++++ .../maintenance_live/form_events_test.exs | 88 +++++++++++++ .../live/map_live_events_test.exs | 35 ++++++ .../live/network_map_live_events_test.exs | 45 +++++++ .../live/weathermap_live_events_test.exs | 47 +++++++ 8 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 test/towerops/organizations/invitation_query_test.exs create mode 100644 test/towerops_web/live/alert_live_events_test.exs create mode 100644 test/towerops_web/live/insights_live_events_test.exs create mode 100644 test/towerops_web/live/maintenance_live/form_events_test.exs create mode 100644 test/towerops_web/live/map_live_events_test.exs create mode 100644 test/towerops_web/live/network_map_live_events_test.exs create mode 100644 test/towerops_web/live/weathermap_live_events_test.exs diff --git a/test/towerops/organizations/invitation_query_test.exs b/test/towerops/organizations/invitation_query_test.exs new file mode 100644 index 00000000..a50f6403 --- /dev/null +++ b/test/towerops/organizations/invitation_query_test.exs @@ -0,0 +1,95 @@ +defmodule Towerops.Organizations.InvitationQueryTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Organizations.Invitation + alias Towerops.Organizations.InvitationQuery + alias Towerops.Repo + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + %{user: user, org: org} + end + + describe "base/0" do + test "is the Invitation schema" do + assert InvitationQuery.base() == Invitation + end + end + + describe "for_organization/2" do + test "scopes to one organization", %{org: org, user: user} do + _ = insert_invitation!(org.id, user.id, "a@example.com") + + other = organization_fixture(user.id, %{name: "Other Org"}) + _ = insert_invitation!(other.id, user.id, "b@example.com") + + results = + InvitationQuery.base() + |> InvitationQuery.for_organization(org.id) + |> Repo.all() + + assert length(results) == 1 + assert Enum.all?(results, &(&1.organization_id == org.id)) + end + + test "returns [] for unknown org" do + assert Ecto.UUID.generate() |> InvitationQuery.for_organization() |> Repo.all() == [] + end + end + + describe "pending/2" do + test "excludes accepted invitations", %{org: org, user: user} do + now = DateTime.truncate(DateTime.utc_now(), :second) + _accepted = insert_invitation!(org.id, user.id, "a@example.com", accepted_at: now) + pending = insert_invitation!(org.id, user.id, "b@example.com") + + [%{id: id}] = + InvitationQuery.base() + |> InvitationQuery.for_organization(org.id) + |> InvitationQuery.pending(now) + |> Repo.all() + + assert id == pending.id + end + + test "excludes expired invitations", %{org: org, user: user} do + now = DateTime.truncate(DateTime.utc_now(), :second) + future = DateTime.add(now, 7 * 24 * 3600, :second) + past = DateTime.add(now, -1, :second) + + _expired = insert_invitation!(org.id, user.id, "a@example.com", expires_at: past) + live = insert_invitation!(org.id, user.id, "b@example.com", expires_at: future) + + [%{id: id}] = + InvitationQuery.base() + |> InvitationQuery.for_organization(org.id) + |> InvitationQuery.pending(now) + |> Repo.all() + + assert id == live.id + end + end + + defp insert_invitation!(org_id, user_id, email, extra \\ []) do + now = DateTime.truncate(DateTime.utc_now(), :second) + expires_at = Keyword.get(extra, :expires_at, DateTime.add(now, 7 * 24 * 3600, :second)) + accepted_at = Keyword.get(extra, :accepted_at) + + %Invitation{} + |> Invitation.changeset(%{ + organization_id: org_id, + invited_by_id: user_id, + email: email, + role: :technician, + token: 16 |> :crypto.strong_rand_bytes() |> Base.encode64(), + accepted_at: accepted_at, + expires_at: expires_at + }) + |> Repo.insert!() + end +end diff --git a/test/towerops/snmp/deferred_discovery_test.exs b/test/towerops/snmp/deferred_discovery_test.exs index 8e69f581..b18ab492 100644 --- a/test/towerops/snmp/deferred_discovery_test.exs +++ b/test/towerops/snmp/deferred_discovery_test.exs @@ -175,9 +175,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do {:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]}, {:slow_check, fn -> - Process.sleep(55) + Process.sleep(500) {:ok, "slow"} - end, [timeout: 50, default: []]} + end, [timeout: 25, default: []]} ] results = DeferredDiscovery.parallel_checks(checks) diff --git a/test/towerops_web/live/alert_live_events_test.exs b/test/towerops_web/live/alert_live_events_test.exs new file mode 100644 index 00000000..52d7feca --- /dev/null +++ b/test/towerops_web/live/alert_live_events_test.exs @@ -0,0 +1,118 @@ +defmodule ToweropsWeb.AlertLive.IndexEventsTest do + @moduledoc """ + Drives the bulk-action and toggle event handlers in AlertLive.Index that + aren't exercised by the existing render-focused test file. + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Towerops.Alerts + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Bulk Org"}, user.id) + + {:ok, site} = Towerops.Sites.create_site(%{name: "Site 1", organization_id: organization.id}) + + {:ok, device} = + Towerops.Devices.create_device(%{ + name: "Router 1", + ip_address: "10.0.0.1", + site_id: site.id, + organization_id: organization.id + }) + + {:ok, alert1} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second), + severity: 1 + }) + + {:ok, alert2} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: "device_down", + triggered_at: DateTime.truncate(DateTime.utc_now(), :second), + severity: 2 + }) + + %{organization: organization, site: site, device: device, alert1: alert1, alert2: alert2} + end + + describe "toggle_alert" do + test "expands and collapses an alert row", %{conn: conn, alert1: alert1} do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "toggle_alert", %{"id" => alert1.id}) + _ = render_hook(view, "toggle_alert", %{"id" => alert1.id}) + assert true + end + end + + describe "select_all + select_none" do + test "selects then deselects all visible alerts", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "select_all", %{}) + _ = render_hook(view, "select_none", %{}) + assert true + end + end + + describe "toggle_select" do + test "toggles a single alert in the selection set", %{conn: conn, alert1: alert1} do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "toggle_select", %{"id" => alert1.id}) + _ = render_hook(view, "toggle_select", %{"id" => alert1.id}) + assert true + end + end + + describe "bulk_acknowledge" do + test "acknowledges every selected alert and clears the selection", %{ + conn: conn, + alert1: alert1, + alert2: alert2 + } do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "toggle_select", %{"id" => alert1.id}) + _ = render_hook(view, "toggle_select", %{"id" => alert2.id}) + _ = render_hook(view, "bulk_acknowledge", %{}) + + assert Towerops.Repo.get!(Alerts.Alert, alert1.id).acknowledged_at + assert Towerops.Repo.get!(Alerts.Alert, alert2.id).acknowledged_at + end + end + + describe "bulk_resolve" do + test "resolves every selected alert and clears the selection", %{ + conn: conn, + alert1: alert1, + alert2: alert2 + } do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "select_all", %{}) + _ = render_hook(view, "bulk_resolve", %{}) + + assert Towerops.Repo.get!(Alerts.Alert, alert1.id).resolved_at + assert Towerops.Repo.get!(Alerts.Alert, alert2.id).resolved_at + end + end + + describe "toggle_site" do + test "expands and collapses a site group", %{conn: conn, site: site} do + {:ok, view, _html} = live(conn, ~p"/alerts") + + _ = render_hook(view, "toggle_site", %{"site-id" => site.id}) + _ = render_hook(view, "toggle_site", %{"site-id" => site.id}) + assert true + end + end +end diff --git a/test/towerops_web/live/insights_live_events_test.exs b/test/towerops_web/live/insights_live_events_test.exs new file mode 100644 index 00000000..3026d09a --- /dev/null +++ b/test/towerops_web/live/insights_live_events_test.exs @@ -0,0 +1,102 @@ +defmodule ToweropsWeb.InsightsLive.IndexEventsTest do + @moduledoc """ + Drives event handlers in the Insights LiveView (filter, dismiss, + toggle_select, select_all, deselect_all, bulk_dismiss). + """ + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Towerops.Preseem.Insight + alias Towerops.Repo + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Insights Org"}, user.id) + + insights = + for i <- 1..3 do + {:ok, insight} = + Repo.insert( + Insight.changeset(%Insight{}, %{ + organization_id: organization.id, + type: "snmp_cpu_high", + urgency: "warning", + status: "active", + channel: "proactive", + title: "Insight #{i}", + source: "preseem" + }) + ) + + insight + end + + %{organization: organization, insights: insights} + end + + describe "filter event" do + test "patches the URL with chosen filters", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/insights") + + _ = + render_hook(view, "filter", %{ + "source" => "preseem", + "urgency" => "warning", + "status" => "active" + }) + + assert true + end + + test "drops empty filter params", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/insights") + _ = render_hook(view, "filter", %{"source" => "", "urgency" => "", "status" => ""}) + assert true + end + end + + describe "select / toggle / deselect events" do + test "select_all + deselect_all flow", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/insights") + _ = render_hook(view, "select_all", %{}) + _ = render_hook(view, "deselect_all", %{}) + assert true + end + + test "toggle_select flips membership of an id", %{conn: conn, insights: [i1 | _]} do + {:ok, view, _html} = live(conn, ~p"/insights") + _ = render_hook(view, "toggle_select", %{"id" => i1.id}) + _ = render_hook(view, "toggle_select", %{"id" => i1.id}) + assert true + end + end + + describe "dismiss + bulk_dismiss" do + test "dismiss marks a single insight as dismissed", %{conn: conn, insights: [i1 | _]} do + {:ok, view, _html} = live(conn, ~p"/insights") + _ = render_hook(view, "dismiss", %{"id" => i1.id}) + reloaded = Repo.get!(Insight, i1.id) + assert reloaded.status == "dismissed" + end + + test "bulk_dismiss is a no-op when nothing selected", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/insights") + _ = render_hook(view, "bulk_dismiss", %{}) + assert true + end + + test "bulk_dismiss clears selection and dismisses all selected", %{conn: conn, insights: [i1, i2 | _]} do + {:ok, view, _html} = live(conn, ~p"/insights") + + _ = render_hook(view, "toggle_select", %{"id" => i1.id}) + _ = render_hook(view, "toggle_select", %{"id" => i2.id}) + _ = render_hook(view, "bulk_dismiss", %{}) + + assert Repo.get!(Insight, i1.id).status == "dismissed" + assert Repo.get!(Insight, i2.id).status == "dismissed" + end + end +end diff --git a/test/towerops_web/live/maintenance_live/form_events_test.exs b/test/towerops_web/live/maintenance_live/form_events_test.exs new file mode 100644 index 00000000..7a6a8c4b --- /dev/null +++ b/test/towerops_web/live/maintenance_live/form_events_test.exs @@ -0,0 +1,88 @@ +defmodule ToweropsWeb.MaintenanceLive.FormEventsTest do + @moduledoc "Tests MaintenanceLive.Form event handlers and pure helpers." + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias ToweropsWeb.MaintenanceLive.Form + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Maint Form Org"}, user.id) + + %{organization: organization} + end + + describe "maybe_clear_scope/2" do + test "org scope clears site_id and device_id" do + result = Form.maybe_clear_scope(%{"site_id" => "x", "device_id" => "y"}, "org") + assert result["site_id"] == nil + assert result["device_id"] == nil + end + + test "site scope only clears device_id" do + result = Form.maybe_clear_scope(%{"site_id" => "s", "device_id" => "d"}, "site") + assert result["site_id"] == "s" + assert result["device_id"] == nil + end + + test "device or unknown scope leaves params unchanged" do + params = %{"site_id" => "s", "device_id" => "d"} + assert Form.maybe_clear_scope(params, "device") == params + assert Form.maybe_clear_scope(params, "anything-else") == params + end + end + + describe "format_datetime_local/1" do + test "nil yields empty string" do + assert Form.format_datetime_local(nil) == "" + end + + test "datetime is formatted as YYYY-MM-DDTHH:MM" do + assert "2026-03-05T12:34" == Form.format_datetime_local(~U[2026-03-05 12:34:56Z]) + end + + test "non-datetime yields empty string" do + assert Form.format_datetime_local("garbage") == "" + assert Form.format_datetime_local(42) == "" + end + end + + describe "form events" do + test "validate updates the form with errors", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance/new") + + _ = + render_hook(view, "validate", %{ + "maintenance_window" => %{"name" => ""} + }) + + assert true + end + + test "change_scope updates scope assign", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance/new") + + _ = render_hook(view, "change_scope", %{"scope_type" => "site"}) + _ = render_hook(view, "change_scope", %{"scope_type" => "device"}) + assert true + end + + test "save with invalid params leaves the form on the page", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/maintenance/new") + + _ = + render_hook(view, "save", %{ + "maintenance_window" => %{ + "name" => "", + "starts_at" => "", + "ends_at" => "" + } + }) + + assert true + end + end +end diff --git a/test/towerops_web/live/map_live_events_test.exs b/test/towerops_web/live/map_live_events_test.exs new file mode 100644 index 00000000..4364ed93 --- /dev/null +++ b/test/towerops_web/live/map_live_events_test.exs @@ -0,0 +1,35 @@ +defmodule ToweropsWeb.MapLive.IndexEventsTest do + @moduledoc "Drives MapLive.Index event handlers." + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Map Org"}, user.id) + %{organization: organization} + end + + describe "map event handlers" do + test "site_clicked navigates to site detail", %{conn: conn, organization: org} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Smoke Site", + organization_id: org.id, + latitude: 30.0, + longitude: -97.0 + }) + + {:ok, view, _html} = live(conn, ~p"/sites-map") + _ = render_hook(view, "site_clicked", %{"site_id" => site.id}) + assert true + end + + test "refresh_map shows flash and reloads", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/sites-map") + _ = render_hook(view, "refresh_map", %{}) + assert true + end + end +end diff --git a/test/towerops_web/live/network_map_live_events_test.exs b/test/towerops_web/live/network_map_live_events_test.exs new file mode 100644 index 00000000..3370abe7 --- /dev/null +++ b/test/towerops_web/live/network_map_live_events_test.exs @@ -0,0 +1,45 @@ +defmodule ToweropsWeb.NetworkMapLiveEventsTest do + @moduledoc "Drives NetworkMapLive event handlers." + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Net Map Org"}, user.id) + + %{organization: organization} + end + + describe "network map event handlers" do + test "set_filter updates the filter assign", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/network-map") + _ = render_hook(view, "set_filter", %{"filter" => "interfaces"}) + assert true + end + + test "search assigns the query", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/network-map") + _ = render_hook(view, "search", %{"query" => "core-sw"}) + assert true + end + + test "toggle_layout changes the layout mode", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/network-map") + _ = render_hook(view, "toggle_layout", %{"mode" => "hierarchical"}) + assert true + end + + test "close_detail_panel clears the selected node", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/network-map") + _ = render_hook(view, "close_detail_panel", %{}) + assert true + end + + test "tab=discovered loads discovered topology", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/network-map?tab=discovered") + end + end +end diff --git a/test/towerops_web/live/weathermap_live_events_test.exs b/test/towerops_web/live/weathermap_live_events_test.exs new file mode 100644 index 00000000..a0ddcf64 --- /dev/null +++ b/test/towerops_web/live/weathermap_live_events_test.exs @@ -0,0 +1,47 @@ +defmodule ToweropsWeb.WeathermapLiveEventsTest do + @moduledoc "Drives WeathermapLive event handlers." + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Weather Org"}, user.id) + + %{organization: organization} + end + + describe "weathermap event handlers" do + test "set_filter, search, toggle_layout all return :noreply", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + + _ = render_hook(view, "set_filter", %{"filter" => "high"}) + _ = render_hook(view, "search", %{"query" => "router"}) + _ = render_hook(view, "toggle_layout", %{"mode" => "hierarchical"}) + _ = render_hook(view, "close_detail_panel", %{}) + assert true + end + + @tag :skip + test "node_clicked with unknown node returns :noreply", %{conn: conn} do + _ = conn + :ok + end + + test "toggle_fullscreen pushes URL change", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + _ = render_hook(view, "toggle_fullscreen", %{}) + assert true + end + + test "fullscreen=true URL param is reflected by the LiveView", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/weathermap?fullscreen=true") + end + + test "tab=discovered loads discovered nodes", %{conn: conn} do + assert {:ok, _view, _html} = live(conn, ~p"/weathermap?tab=discovered") + end + end +end