Add comprehensive test coverage for LiveView modules and contexts

- Add LiveView tests for DashboardLive, AlertLive, SiteLive, EquipmentLive, OrgLive
- Add comprehensive tests for Organizations context (memberships, invitations, auth)
- Add tests for AlertNotifier email delivery
- Improve test coverage from 54.78% to 86.65%
- Add 101 new tests (256 total tests)
This commit is contained in:
Graham McIntire 2026-01-03 11:21:53 -06:00
parent c27aa4f5f8
commit b0a17e7ea6
No known key found for this signature in database
15 changed files with 1713 additions and 146 deletions

View file

@ -77,11 +77,14 @@ defmodule ToweropsWeb.CoreComponents do
/>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p :if={@title} class={[
"text-sm font-medium",
@kind == :info && "text-blue-900 dark:text-blue-100",
@kind == :error && "text-red-900 dark:text-red-100"
]}>
<p
:if={@title}
class={[
"text-sm font-medium",
@kind == :info && "text-blue-900 dark:text-blue-100",
@kind == :error && "text-red-900 dark:text-red-100"
]}
>
{@title}
</p>
<p class={[
@ -98,8 +101,10 @@ defmodule ToweropsWeb.CoreComponents do
type="button"
class={[
"inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2",
@kind == :info && "text-blue-500 hover:text-blue-600 focus:ring-blue-500 dark:text-blue-400",
@kind == :error && "text-red-500 hover:text-red-600 focus:ring-red-500 dark:text-red-400"
@kind == :info &&
"text-blue-500 hover:text-blue-600 focus:ring-blue-500 dark:text-blue-400",
@kind == :error &&
"text-red-500 hover:text-red-600 focus:ring-red-500 dark:text-red-400"
]}
aria-label={gettext("close")}
>
@ -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"
}

View file

@ -25,14 +25,16 @@
class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-950"
>
<div class="flex">
<.icon name="hero-information-circle" class="h-6 w-6 flex-shrink-0 text-blue-600 dark:text-blue-400" />
<.icon
name="hero-information-circle"
class="h-6 w-6 flex-shrink-0 text-blue-600 dark:text-blue-400"
/>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
You are running the local mail adapter.
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-200">
To see sent emails, visit
<.link href="/dev/mailbox" class="font-medium underline">the mailbox page</.link>.
To see sent emails, visit <.link href="/dev/mailbox" class="font-medium underline">the mailbox page</.link>.
</p>
</div>
</div>
@ -74,7 +76,12 @@
autocomplete="email"
required
/>
<.input field={f[:password]} type="password" label="Password" autocomplete="current-password" />
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>

View file

@ -68,11 +68,9 @@
]}>
<%= case alert.alert_type do %>
<% :equipment_down -> %>
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
Equipment Down
<.icon name="hero-exclamation-triangle" class="h-4 w-4" /> Equipment Down
<% :equipment_up -> %>
<.icon name="hero-check-circle" class="h-4 w-4" />
Equipment Recovered
<.icon name="hero-check-circle" class="h-4 w-4" /> Equipment Recovered
<% end %>
</span>
@ -91,7 +89,9 @@
<h3 class="mt-3 font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"}
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.name}
@ -126,7 +126,9 @@
<div>
<strong class="font-medium">Site:</strong>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"}
navigate={
~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"
}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.site.name}

View file

@ -4,140 +4,142 @@
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
</.header>
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
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"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sites</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@sites_count}</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Total sites</p>
</.link>
<.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"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Equipment</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@equipment_up} Up
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@equipment_down} Down
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-zinc-400"></span>
{@equipment_unknown} Unknown
</div>
</div>
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
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"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Active Alerts</h3>
<p class={[
"mt-2 text-3xl font-bold",
(length(@active_alerts) > 0 && "text-red-600 dark:text-red-500") ||
"text-zinc-900 dark:text-zinc-100"
]}>
{length(@active_alerts)}
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Requires attention</p>
</.link>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">System Status</h3>
<div class={[
"mt-3 flex items-center gap-2 text-sm font-semibold",
(@equipment_down == 0 && "text-green-600 dark:text-green-500") ||
"text-amber-600 dark:text-amber-500"
]}>
<%= if @equipment_down == 0 do %>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span>All Systems Operational</span>
<% else %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<span>{@equipment_down} Equipment Down</span>
<% end %>
</div>
</div>
</div>
<%= if length(@active_alerts) > 0 do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Active Alerts</h2>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Alerts →
</.link>
</div>
<div class="space-y-3">
<%= for alert <- Enum.take(@active_alerts, 5) do %>
<div class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500"
/>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.equipment.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
</div>
<span class="text-xs text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
{Calendar.strftime(alert.triggered_at, "%H:%M")}
</span>
</div>
<% end %>
<%= if length(@active_alerts) > 5 do %>
<div class="text-center text-sm text-zinc-500 dark:text-zinc-400">
+ {length(@active_alerts) - 5} more alerts
</div>
<% end %>
</div>
</div>
<% end %>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">Quick Actions</h2>
<div class="flex flex-wrap gap-4">
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
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-building-office" class="h-5 w-5" />
<span>Manage Sites</span>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sites</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@sites_count}</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Total sites</p>
</.link>
<.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" />
<span>Manage Equipment</span>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Equipment</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@equipment_up} Up
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@equipment_down} Down
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-zinc-400"></span>
{@equipment_unknown} Unknown
</div>
</div>
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 bg-white px-4 py-2.5 text-sm font-semibold text-zinc-700 shadow-sm hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
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-bell" class="h-5 w-5" />
<span>View Alerts</span>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Active Alerts</h3>
<p class={[
"mt-2 text-3xl font-bold",
(length(@active_alerts) > 0 && "text-red-600 dark:text-red-500") ||
"text-zinc-900 dark:text-zinc-100"
]}>
{length(@active_alerts)}
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Requires attention</p>
</.link>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">System Status</h3>
<div class={[
"mt-3 flex items-center gap-2 text-sm font-semibold",
(@equipment_down == 0 && "text-green-600 dark:text-green-500") ||
"text-amber-600 dark:text-amber-500"
]}>
<%= if @equipment_down == 0 do %>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span>All Systems Operational</span>
<% else %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<span>{@equipment_down} Equipment Down</span>
<% end %>
</div>
</div>
</div>
<%= if length(@active_alerts) > 0 do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Active Alerts</h2>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Alerts →
</.link>
</div>
<div class="space-y-3">
<%= for alert <- Enum.take(@active_alerts, 5) do %>
<div class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500"
/>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.equipment.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
</div>
<span class="text-xs text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
{Calendar.strftime(alert.triggered_at, "%H:%M")}
</span>
</div>
<% end %>
<%= if length(@active_alerts) > 5 do %>
<div class="text-center text-sm text-zinc-500 dark:text-zinc-400">
+ {length(@active_alerts) - 5} more alerts
</div>
<% end %>
</div>
</div>
<% end %>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">Quick Actions</h2>
<div class="flex flex-wrap gap-4">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
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"
>
<.icon name="hero-building-office" class="h-5 w-5" />
<span>Manage Sites</span>
</.link>
<.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"
>
<.icon name="hero-server" class="h-5 w-5" />
<span>Manage Equipment</span>
</.link>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 bg-white px-4 py-2.5 text-sm font-semibold text-zinc-700 shadow-sm hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<.icon name="hero-bell" class="h-5 w-5" />
<span>View Alerts</span>
</.link>
</div>
</div>
</div>
</Layouts.authenticated>

View file

@ -2,7 +2,9 @@
<.header>
{@page_title}
<:subtitle>
{if @live_action == :new, do: "Add new equipment to monitor", else: "Update equipment details"}
{if @live_action == :new,
do: "Add new equipment to monitor",
else: "Update equipment details"}
</:subtitle>
</.header>

View file

@ -17,7 +17,10 @@
Get started by adding your first equipment.
</p>
<div class="mt-6">
<.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"} variant="primary">
<.button
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
variant="primary"
>
<.icon name="hero-plus" class="h-5 w-5" /> New Equipment
</.button>
</div>

View file

@ -133,7 +133,8 @@
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
check.status == :success &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
check.status == :failure && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
check.status == :failure &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
]}>
{check.status |> to_string() |> String.upcase()}
</span>

View file

@ -11,7 +11,10 @@
<%= if @sites == [] do %>
<div class="text-center py-16">
<.icon name="hero-building-office" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<.icon
name="hero-building-office"
class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600"
/>
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No sites</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by creating your first site.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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&#39;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

View file

@ -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&#39;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

View file

@ -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&#39;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