diff --git a/lib/towerops_web/components/core_components.ex b/lib/towerops_web/components/core_components.ex index 532745f9..2bbf9c90 100644 --- a/lib/towerops_web/components/core_components.ex +++ b/lib/towerops_web/components/core_components.ex @@ -77,11 +77,14 @@ defmodule ToweropsWeb.CoreComponents do />
+
{@title}
@@ -130,8 +135,7 @@ defmodule ToweropsWeb.CoreComponents do def button(%{rest: rest} = assigns) do variants = %{ - "primary" => - "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600", + "primary" => "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600", nil => "bg-white text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 focus:ring-blue-500 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700" } diff --git a/lib/towerops_web/controllers/user_session_html/new.html.heex b/lib/towerops_web/controllers/user_session_html/new.html.heex index f284d8fd..d3d4b4b0 100644 --- a/lib/towerops_web/controllers/user_session_html/new.html.heex +++ b/lib/towerops_web/controllers/user_session_html/new.html.heex @@ -25,14 +25,16 @@ class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-950" >
You are running the local mail adapter.
- To see sent emails, visit - <.link href="/dev/mailbox" class="font-medium underline">the mailbox page. + To see sent emails, visit <.link href="/dev/mailbox" class="font-medium underline">the mailbox page.
{@sites_count}
-Total sites
- - - <.link - navigate={~p"/orgs/#{@current_organization.slug}/equipment"} - class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900" - > -{@equipment_count}
-0 && "text-red-600 dark:text-red-500") || - "text-zinc-900 dark:text-zinc-100" - ]}> - {length(@active_alerts)} -
-Requires attention
- - -{alert.message}
-{@sites_count}
+Total sites
+ <.link navigate={~p"/orgs/#{@current_organization.slug}/equipment"} - class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600" + class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900" > - <.icon name="hero-server" class="h-5 w-5" /> - Manage Equipment +{@equipment_count}
+0 && "text-red-600 dark:text-red-500") || + "text-zinc-900 dark:text-zinc-100" + ]}> + {length(@active_alerts)} +
+Requires attention
+ +{alert.message}
+Get started by creating your first site. diff --git a/test/towerops/alerts/alert_notifier_test.exs b/test/towerops/alerts/alert_notifier_test.exs new file mode 100644 index 00000000..dce3fc59 --- /dev/null +++ b/test/towerops/alerts/alert_notifier_test.exs @@ -0,0 +1,167 @@ +defmodule Towerops.Alerts.AlertNotifierTest do + use Towerops.DataCase + + import Swoosh.TestAssertions + import Towerops.AccountsFixtures + + alias Towerops.Alerts.AlertNotifier + + setup do + owner = user_fixture() + admin = user_fixture() + member = user_fixture() + + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, owner.id) + + # Add admin and member + {:ok, _admin_membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: admin.id, + role: :admin + }) + + {:ok, _member_membership} = + Towerops.Organizations.create_membership(%{ + organization_id: organization.id, + user_id: member.id, + role: :member + }) + + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Test Router", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{owner: owner, admin: admin, member: member, organization: organization, equipment: equipment} + end + + describe "deliver_alert_notification/1" do + test "sends equipment_down alert to owners and admins", %{ + owner: owner, + admin: admin, + organization: organization, + equipment: equipment + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, results} = AlertNotifier.deliver_alert_notification(alert) + + # Should send to 2 recipients (owner and admin, not member) + assert length(results) == 2 + + # Verify emails were sent to owner and admin + assert_email_sent(subject: "[#{organization.name}] Equipment Down: #{equipment.name}") + assert_email_sent(to: owner.email) + assert_email_sent(to: admin.email) + end + + test "sends equipment_up alert to owners and admins", %{ + owner: owner, + admin: admin, + equipment: equipment, + organization: organization + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_up, + triggered_at: DateTime.utc_now(), + message: "Equipment recovered" + }) + + {:ok, results} = AlertNotifier.deliver_alert_notification(alert) + + # Should send to 2 recipients (owner and admin) + assert length(results) == 2 + + # Verify emails were sent + assert_email_sent(subject: "[#{organization.name}] Equipment Recovered: #{equipment.name}") + assert_email_sent(to: owner.email) + assert_email_sent(to: admin.email) + end + + test "equipment_down alert includes correct information", %{ + owner: owner, + equipment: equipment, + organization: organization + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) + + assert_email_sent(fn email -> + email.to == [{nil, owner.email}] && + email.subject =~ "Equipment Down" && + email.text_body =~ organization.name && + email.text_body =~ equipment.name && + email.text_body =~ equipment.ip_address && + email.text_body =~ "Test Site" && + email.text_body =~ "not responding to ping checks" + end) + end + + test "equipment_up alert includes correct information", %{ + owner: owner, + equipment: equipment, + organization: organization + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_up, + triggered_at: DateTime.utc_now(), + message: "Equipment recovered" + }) + + {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) + + assert_email_sent(fn email -> + email.to == [{nil, owner.email}] && + email.subject =~ "Equipment Recovered" && + email.text_body =~ organization.name && + email.text_body =~ equipment.name && + email.text_body =~ equipment.ip_address && + email.text_body =~ "Test Site" && + email.text_body =~ "now responding to ping checks" + end) + end + + test "uses correct from address", %{equipment: equipment} do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, _results} = AlertNotifier.deliver_alert_notification(alert) + + assert_email_sent(fn email -> + email.from == {"TowerOps Alerts", "alerts@towerops.example.com"} + end) + end + end +end diff --git a/test/towerops/organizations_test.exs b/test/towerops/organizations_test.exs new file mode 100644 index 00000000..dfaaa83a --- /dev/null +++ b/test/towerops/organizations_test.exs @@ -0,0 +1,394 @@ +defmodule Towerops.OrganizationsTest do + use Towerops.DataCase + + import Towerops.AccountsFixtures + + alias Towerops.Organizations + + describe "organizations" do + setup do + user = user_fixture() + %{user: user} + end + + test "list_user_organizations/1 returns all organizations for a user", %{user: user} do + {:ok, org1} = Organizations.create_organization(%{name: "Org 1"}, user.id) + {:ok, org2} = Organizations.create_organization(%{name: "Org 2"}, user.id) + + orgs = Organizations.list_user_organizations(user.id) + assert length(orgs) == 2 + org_ids = Enum.map(orgs, & &1.id) + assert org1.id in org_ids + assert org2.id in org_ids + end + + test "list_user_organizations/1 returns empty list for user with no orgs" do + user = user_fixture() + assert Organizations.list_user_organizations(user.id) == [] + end + + test "get_organization!/1 returns the organization with given id", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + assert Organizations.get_organization!(organization.id).id == organization.id + end + + test "get_organization_by_slug!/1 returns the organization with given slug", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + assert Organizations.get_organization_by_slug!(organization.slug).id == organization.id + end + + test "create_organization/2 with valid data creates organization", %{user: user} do + valid_attrs = %{name: "New Organization"} + + assert {:ok, organization} = Organizations.create_organization(valid_attrs, user.id) + assert organization.name == "New Organization" + assert organization.slug + end + + test "create_organization/2 creates owner membership", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + + membership = Organizations.get_membership(organization.id, user.id) + assert membership.role == :owner + end + + test "create_organization/2 with invalid data returns error changeset" do + user = user_fixture() + assert {:error, changeset} = Organizations.create_organization(%{name: nil}, user.id) + assert "can't be blank" in errors_on(changeset).name + end + + test "update_organization/2 with valid data updates the organization", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + + update_attrs = %{name: "Updated Org"} + assert {:ok, updated} = Organizations.update_organization(organization, update_attrs) + assert updated.name == "Updated Org" + end + + test "update_organization/2 with invalid data returns error changeset", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + + assert {:error, changeset} = Organizations.update_organization(organization, %{name: nil}) + assert "can't be blank" in errors_on(changeset).name + end + + test "delete_organization/1 deletes the organization", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + assert {:ok, _} = Organizations.delete_organization(organization) + assert_raise Ecto.NoResultsError, fn -> Organizations.get_organization!(organization.id) end + end + + test "change_organization/1 returns an organization changeset", %{user: user} do + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + assert %Ecto.Changeset{} = Organizations.change_organization(organization) + end + end + + describe "memberships" do + setup do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + %{user: user, organization: organization} + end + + test "get_membership/2 returns the membership", %{user: user, organization: organization} do + membership = Organizations.get_membership(organization.id, user.id) + assert membership.user_id == user.id + assert membership.organization_id == organization.id + end + + test "get_membership/2 returns nil when membership doesn't exist", %{organization: organization} do + other_user = user_fixture() + assert Organizations.get_membership(organization.id, other_user.id) == nil + end + + test "get_membership!/2 returns the membership", %{user: user, organization: organization} do + membership = Organizations.get_membership!(organization.id, user.id) + assert membership.user_id == user.id + end + + test "get_membership!/2 raises when membership doesn't exist", %{organization: organization} do + other_user = user_fixture() + + assert_raise Ecto.NoResultsError, fn -> + Organizations.get_membership!(organization.id, other_user.id) + end + end + + test "list_organization_memberships/1 returns all memberships", %{ + user: user, + organization: organization + } do + # Add another member + other_user = user_fixture() + + {:ok, _membership} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: other_user.id, + role: :member + }) + + memberships = Organizations.list_organization_memberships(organization.id) + assert length(memberships) == 2 + user_ids = Enum.map(memberships, & &1.user_id) + assert user.id in user_ids + assert other_user.id in user_ids + end + + test "list_organization_notification_recipients/1 returns owners and admins", %{ + organization: organization + } do + admin = user_fixture() + member = user_fixture() + + {:ok, _admin_membership} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: admin.id, + role: :admin + }) + + {:ok, _member_membership} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: member.id, + role: :member + }) + + recipients = Organizations.list_organization_notification_recipients(organization.id) + recipient_ids = Enum.map(recipients, & &1.id) + + # Owner and admin should be in recipients + assert admin.id in recipient_ids + # Member should NOT be in recipients + refute member.id in recipient_ids + end + + test "create_membership/1 with valid data creates membership", %{organization: organization} do + new_user = user_fixture() + + valid_attrs = %{ + organization_id: organization.id, + user_id: new_user.id, + role: :member + } + + assert {:ok, membership} = Organizations.create_membership(valid_attrs) + assert membership.role == :member + assert membership.user_id == new_user.id + end + + test "create_membership/1 with invalid data returns error changeset" do + assert {:error, changeset} = Organizations.create_membership(%{}) + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "update_membership/2 changes user role", %{user: user, organization: organization} do + membership = Organizations.get_membership!(organization.id, user.id) + + assert {:ok, updated} = Organizations.update_membership(membership, %{role: :admin}) + assert updated.role == :admin + end + + test "delete_membership/1 deletes the membership", %{organization: organization} do + new_user = user_fixture() + + {:ok, membership} = + Organizations.create_membership(%{ + organization_id: organization.id, + user_id: new_user.id, + role: :member + }) + + assert {:ok, _} = Organizations.delete_membership(membership) + assert Organizations.get_membership(organization.id, new_user.id) == nil + end + end + + describe "invitations" do + setup do + owner = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, owner.id) + %{owner: owner, organization: organization} + end + + test "list_pending_invitations/1 returns pending invitations", %{ + owner: owner, + organization: organization + } do + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "invited@example.com", + role: :member, + invited_by_id: owner.id, + token: "test-token-#{System.unique_integer()}", + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + }) + + invitations = Organizations.list_pending_invitations(organization.id) + assert length(invitations) == 1 + assert hd(invitations).id == invitation.id + end + + test "list_pending_invitations/1 excludes accepted invitations", %{ + owner: owner, + organization: organization + } do + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "invited@example.com", + role: :member, + invited_by_id: owner.id, + token: "test-token-#{System.unique_integer()}", + expires_at: DateTime.add(DateTime.utc_now(), 7, :day), + accepted_at: DateTime.utc_now() + }) + + invitations = Organizations.list_pending_invitations(organization.id) + refute Enum.any?(invitations, &(&1.id == invitation.id)) + end + + test "list_pending_invitations/1 excludes expired invitations", %{ + owner: owner, + organization: organization + } do + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "invited@example.com", + role: :member, + invited_by_id: owner.id, + token: "test-token-#{System.unique_integer()}", + expires_at: DateTime.add(DateTime.utc_now(), -1, :day) + }) + + invitations = Organizations.list_pending_invitations(organization.id) + refute Enum.any?(invitations, &(&1.id == invitation.id)) + end + + test "create_invitation/1 with valid data creates invitation", %{ + owner: owner, + organization: organization + } do + valid_attrs = %{ + organization_id: organization.id, + email: "test@example.com", + role: :member, + invited_by_id: owner.id, + token: "unique-token-#{System.unique_integer()}", + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + } + + assert {:ok, invitation} = Organizations.create_invitation(valid_attrs) + assert invitation.email == "test@example.com" + assert invitation.role == :member + end + + test "get_invitation_by_token/1 returns invitation", %{ + owner: owner, + organization: organization + } do + token = "test-token-#{System.unique_integer()}" + + {:ok, _invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "test@example.com", + role: :member, + invited_by_id: owner.id, + token: token, + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + }) + + invitation = Organizations.get_invitation_by_token(token) + assert invitation.token == token + end + + test "get_invitation_by_token/1 returns nil for accepted invitation", %{ + owner: owner, + organization: organization + } do + token = "test-token-#{System.unique_integer()}" + + {:ok, _invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "test@example.com", + role: :member, + invited_by_id: owner.id, + token: token, + expires_at: DateTime.add(DateTime.utc_now(), 7, :day), + accepted_at: DateTime.utc_now() + }) + + assert Organizations.get_invitation_by_token(token) == nil + end + + test "accept_invitation/2 creates membership and marks invitation accepted", %{ + owner: owner, + organization: organization + } do + token = "test-token-#{System.unique_integer()}" + + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "test@example.com", + role: :member, + invited_by_id: owner.id, + token: token, + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + }) + + new_user = user_fixture() + assert {:ok, membership} = Organizations.accept_invitation(invitation, new_user.id) + assert membership.user_id == new_user.id + assert membership.role == :member + + # Invitation should no longer be retrievable + assert Organizations.get_invitation_by_token(token) == nil + end + + test "delete_invitation/1 deletes the invitation", %{ + owner: owner, + organization: organization + } do + {:ok, invitation} = + Organizations.create_invitation(%{ + organization_id: organization.id, + email: "test@example.com", + role: :member, + invited_by_id: owner.id, + token: "test-token-#{System.unique_integer()}", + expires_at: DateTime.add(DateTime.utc_now(), 7, :day) + }) + + assert {:ok, _} = Organizations.delete_invitation(invitation) + assert Organizations.list_pending_invitations(organization.id) == [] + end + end + + describe "authorization" do + setup do + user = user_fixture() + {:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id) + membership = Organizations.get_membership!(organization.id, user.id) + %{membership: membership} + end + + test "can?/3 delegates to Policy module", %{membership: membership} do + # Owner can delete organization + assert Organizations.can?(membership, :delete, :organization) + + # Change to member role + {:ok, member_membership} = Organizations.update_membership(membership, %{role: :member}) + + # Member cannot delete organization + refute Organizations.can?(member_membership, :delete, :organization) + end + end +end diff --git a/test/towerops_web/live/alert_live_test.exs b/test/towerops_web/live/alert_live_test.exs new file mode 100644 index 00000000..9b692876 --- /dev/null +++ b/test/towerops_web/live/alert_live_test.exs @@ -0,0 +1,199 @@ +defmodule ToweropsWeb.AlertLive.IndexTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + 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 + }) + + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Test Router", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{organization: organization, site: site, equipment: equipment} + end + + describe "Index" do + test "displays alerts page", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + assert html =~ "Alerts" + assert html =~ "Monitor and acknowledge system alerts" + end + + test "displays empty state when no alerts", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + assert html =~ "No alerts" + end + + test "lists all alerts", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, _alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + assert html =~ "Test Router" + assert html =~ "Equipment is down" + assert html =~ "Equipment Down" + end + + test "filters active alerts", %{conn: conn, organization: organization, equipment: equipment} do + # Create an active equipment_down alert + {:ok, _active_alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + # Create a recovery alert (equipment_up) + {:ok, _recovery_alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_up, + triggered_at: DateTime.utc_now(), + message: "Equipment recovered" + }) + + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/alerts?filter=active") + + # Should show equipment_down alert + assert html =~ "Equipment is down" + # Should NOT show equipment_up alert in active filter + refute html =~ "Equipment recovered" + end + + test "acknowledges an alert", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + html = + view + |> element("button", "Acknowledge") + |> render_click(%{"id" => alert.id}) + + assert html =~ "Alert acknowledged" + end + + test "does not show acknowledge button for equipment_up alerts", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, _alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_up, + triggered_at: DateTime.utc_now(), + message: "Equipment recovered" + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + # Should not show acknowledge button for equipment_up alerts + refute html =~ "Acknowledge" + end + + test "does not show acknowledge button for resolved alerts", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + # Resolve the alert + Towerops.Alerts.resolve_alert(alert) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + assert html =~ "Resolved" + refute html =~ "Acknowledge" + end + + test "updates in real-time when new alert is created", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + # Broadcast new alert event + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:new", + {:new_alert, equipment.id, :equipment_down} + ) + + # View should update + assert render(view) + end + + test "updates in real-time when alert is resolved", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + # Resolve the alert and broadcast + Towerops.Alerts.resolve_alert(alert) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:resolved", + {:alert_resolved, equipment.id, :equipment_down} + ) + + # View should update + assert render(view) + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/alerts") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/towerops_web/live/dashboard_live_test.exs b/test/towerops_web/live/dashboard_live_test.exs new file mode 100644 index 00000000..057d4988 --- /dev/null +++ b/test/towerops_web/live/dashboard_live_test.exs @@ -0,0 +1,133 @@ +defmodule ToweropsWeb.DashboardLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + 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 "Dashboard" do + test "displays organization name and stats", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}") + + assert html =~ organization.name + assert html =~ "Sites" + assert html =~ "Equipment" + assert html =~ "Active Alerts" + end + + test "displays correct site count", %{conn: conn, organization: organization} do + # Create additional sites + {:ok, _site2} = + Towerops.Sites.create_site(%{ + name: "Site 2", + organization_id: organization.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}") + assert html =~ "Sites" + end + + test "displays correct equipment counts", %{conn: conn, organization: organization, site: site} do + # Create equipment with different statuses + {:ok, equipment_up} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id, + monitoring_enabled: true + }) + + Towerops.Equipment.update_equipment_status(equipment_up, :up) + + {:ok, equipment_down} = + Towerops.Equipment.create_equipment(%{ + name: "Router 2", + ip_address: "192.168.1.2", + site_id: site.id, + monitoring_enabled: true + }) + + Towerops.Equipment.update_equipment_status(equipment_down, :down) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}") + assert html =~ "Equipment" + assert html =~ "UP" + assert html =~ "DOWN" + end + + test "displays active alerts count", %{conn: conn, organization: organization, site: site} do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + # Create an active alert + {:ok, _alert} = + Towerops.Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: DateTime.utc_now(), + message: "Equipment is down" + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}") + assert html =~ "Active Alerts" + end + + test "updates in real-time when new alert is triggered", %{ + conn: conn, + organization: organization, + site: site + } do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}") + + # Trigger a new alert via PubSub + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:new", + {:new_alert, equipment.id, :equipment_down} + ) + + # View should update + assert render(view) + end + + test "stat cards are clickable links", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}") + + # Check for navigation links + assert html =~ ~p"/orgs/#{organization.slug}/sites" + assert html =~ ~p"/orgs/#{organization.slug}/equipment" + assert html =~ ~p"/orgs/#{organization.slug}/alerts" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/towerops_web/live/equipment_live_test.exs b/test/towerops_web/live/equipment_live_test.exs new file mode 100644 index 00000000..2a302e2d --- /dev/null +++ b/test/towerops_web/live/equipment_live_test.exs @@ -0,0 +1,296 @@ +defmodule ToweropsWeb.EquipmentLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + 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" do + test "lists all equipment", %{conn: conn, organization: organization, site: site} do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + description: "Main router", + site_id: site.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "Equipment" + assert html =~ equipment.name + assert html =~ "192.168.1.1" + end + + test "displays empty state when no equipment", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "No equipment" + end + + test "has link to add new equipment", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "New Equipment" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "Show" do + setup %{site: site} do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + description: "Main router", + site_id: site.id, + monitoring_enabled: true, + check_interval_seconds: 300 + }) + + %{equipment: equipment} + end + + test "displays equipment details", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ equipment.name + assert html =~ "192.168.1.1" + assert html =~ "Main router" + end + + test "displays monitoring status", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "Monitoring" + assert html =~ "Enabled" + assert html =~ "300 seconds" + end + + test "displays site link", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "Test Site" + end + + test "displays recent checks", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + # Create a check + {:ok, _check} = + Towerops.Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 10, + checked_at: DateTime.utc_now() + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "Recent Checks" + assert html =~ "10ms" + end + + test "displays empty state when no checks", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "No monitoring checks yet" + end + + test "triggers manual check", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + html = + view + |> element("button", "Check Now") + |> render_click() + + assert html =~ "Check triggered" + end + + test "deletes equipment", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + {:ok, _, html} = + view + |> element("button", "Delete") + |> render_click() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "Equipment deleted successfully" + end + + test "requires authentication", %{organization: organization, equipment: equipment} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "New" do + test "renders new equipment form", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + assert html =~ "New Equipment" + assert html =~ "Add new equipment to monitor" + end + + test "creates new equipment", %{conn: conn, organization: organization, site: site} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + {:ok, _, html} = + view + |> form("#equipment-form", + equipment: %{ + name: "New Router", + ip_address: "192.168.1.100", + site_id: site.id, + description: "Test router", + monitoring_enabled: true, + check_interval_seconds: 300 + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "Equipment created successfully" + assert html =~ "New Router" + end + + test "validates required fields", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + html = + view + |> form("#equipment-form", equipment: %{name: "", ip_address: ""}) + |> render_submit() + + assert html =~ "can't be blank" + end + + test "validates IP address format", %{conn: conn, organization: organization, site: site} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + html = + view + |> form("#equipment-form", + equipment: %{ + name: "Router", + ip_address: "invalid-ip", + site_id: site.id + } + ) + |> render_submit() + + assert html =~ "must be a valid IPv4 or IPv6 address" + end + + test "pre-selects site from query param", %{ + conn: conn, + organization: organization, + site: site + } do + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/new?site_id=#{site.id}") + + assert html =~ "New Equipment" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "Edit" do + setup %{site: site} do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{equipment: equipment} + end + + test "renders edit form", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + assert html =~ "Edit Equipment" + assert html =~ "Update equipment details" + end + + test "updates equipment", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + {:ok, _, html} = + view + |> form("#equipment-form", + equipment: %{ + name: "Updated Router", + ip_address: "192.168.1.2" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "Equipment updated successfully" + assert html =~ "Updated Router" + end + + test "requires authentication", %{organization: organization, equipment: equipment} do + conn = build_conn() + + {:error, redirect} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/towerops_web/live/org_live_test.exs b/test/towerops_web/live/org_live_test.exs new file mode 100644 index 00000000..7b9a2dd7 --- /dev/null +++ b/test/towerops_web/live/org_live_test.exs @@ -0,0 +1,116 @@ +defmodule ToweropsWeb.OrgLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + describe "Index" do + test "lists all user organizations", %{conn: conn, user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, _view, html} = live(conn, ~p"/orgs") + + assert html =~ "Organizations" + assert html =~ organization.name + end + + test "displays empty state when user has no organizations", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/orgs") + + assert html =~ "No organizations" + end + + test "has link to create new organization", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/orgs") + + assert html =~ "New Organization" + end + + test "navigates to organization dashboard when clicking open button", %{conn: conn, user: user} do + {:ok, organization} = + Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + {:ok, view, _html} = live(conn, ~p"/orgs") + + {:ok, _, html} = + view + |> element("a", "Open") + |> render_click() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}") + + assert html =~ organization.name + end + + test "requires authentication" do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "New" do + test "renders new organization form", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/orgs/new") + + assert html =~ "New Organization" + end + + test "creates new organization", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/orgs/new") + + {:ok, _, html} = + view + |> form("#organization-form", + organization: %{ + name: "New Organization" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs") + + assert html =~ "Organization created successfully" + assert html =~ "New Organization" + end + + test "validates required fields", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/orgs/new") + + html = + view + |> form("#organization-form", organization: %{name: ""}) + |> render_submit() + + assert html =~ "can't be blank" + end + + test "validates unique organization name", %{conn: conn, user: user} do + {:ok, _organization} = + Towerops.Organizations.create_organization(%{name: "Existing Org"}, user.id) + + {:ok, view, _html} = live(conn, ~p"/orgs/new") + + html = + view + |> form("#organization-form", + organization: %{ + name: "Existing Org" + } + ) + |> render_submit() + + assert html =~ "has already been taken" + end + + test "requires authentication" do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/new") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end diff --git a/test/towerops_web/live/site_live_test.exs b/test/towerops_web/live/site_live_test.exs new file mode 100644 index 00000000..78b97496 --- /dev/null +++ b/test/towerops_web/live/site_live_test.exs @@ -0,0 +1,238 @@ +defmodule ToweropsWeb.SiteLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + + setup :register_and_log_in_user + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + %{organization: organization} + end + + describe "Index" do + test "lists all sites", %{conn: conn, organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + location: "Building A", + organization_id: organization.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites") + + assert html =~ "Sites" + assert html =~ site.name + assert html =~ "Building A" + end + + test "displays empty state when no sites", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites") + + assert html =~ "No sites" + end + + test "has link to create new site", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites") + + assert html =~ "New Site" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/sites") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "Show" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + location: "Building A", + description: "Main site", + organization_id: organization.id + }) + + %{site: site} + end + + test "displays site details", %{conn: conn, organization: organization, site: site} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert html =~ site.name + assert html =~ "Building A" + assert html =~ "Main site" + end + + test "displays equipment at site", %{conn: conn, organization: organization, site: site} do + {:ok, equipment} = + Towerops.Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert html =~ equipment.name + assert html =~ "192.168.1.1" + end + + test "displays empty state when no equipment", %{ + conn: conn, + organization: organization, + site: site + } do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert html =~ "No equipment at this site yet" + end + + test "displays parent site link", %{conn: conn, organization: organization, site: site} do + {:ok, parent_site} = + Towerops.Sites.create_site(%{ + name: "Parent Site", + organization_id: organization.id + }) + + {:ok, updated_site} = + Towerops.Sites.update_site(site, %{parent_site_id: parent_site.id}) + + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/sites/#{updated_site.id}") + + assert html =~ "Parent Site" + assert html =~ parent_site.name + end + + test "displays child sites", %{conn: conn, organization: organization, site: site} do + {:ok, child_site} = + Towerops.Sites.create_site(%{ + name: "Child Site", + parent_site_id: site.id, + organization_id: organization.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert html =~ "Child Sites" + assert html =~ child_site.name + end + + test "deletes site", %{conn: conn, organization: organization, site: site} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + {:ok, _, html} = + view + |> element("button", "Delete") + |> render_click() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/sites") + + assert html =~ "Site deleted successfully" + end + + test "requires authentication", %{organization: organization, site: site} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "New" do + test "renders new site form", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/new") + + assert html =~ "New Site" + assert html =~ "Add a new site to your organization" + end + + test "creates new site", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/sites/new") + + {:ok, _, html} = + view + |> form("#site-form", + site: %{ + name: "New Site", + location: "Building B", + description: "New location" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/sites") + + assert html =~ "Site created successfully" + assert html =~ "New Site" + end + + test "validates required fields", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/sites/new") + + html = + view + |> form("#site-form", site: %{name: ""}) + |> render_submit() + + assert html =~ "can't be blank" + end + + test "requires authentication", %{organization: organization} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/sites/new") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "Edit" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + %{site: site} + end + + test "renders edit form", %{conn: conn, organization: organization, site: site} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}/edit") + + assert html =~ "Edit Site" + assert html =~ "Update site details" + end + + test "updates site", %{conn: conn, organization: organization, site: site} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}/edit") + + {:ok, _, html} = + view + |> form("#site-form", + site: %{ + name: "Updated Site", + location: "New Location" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}") + + assert html =~ "Site updated successfully" + assert html =~ "Updated Site" + end + + test "requires authentication", %{organization: organization, site: site} do + conn = build_conn() + {:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/sites/#{site.id}/edit") + + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end +end