diff --git a/lib/towerops_web/live/weathermap_live.html.heex b/lib/towerops_web/live/weathermap_live.html.heex
index 79b350b7..49c46bd3 100644
--- a/lib/towerops_web/live/weathermap_live.html.heex
+++ b/lib/towerops_web/live/weathermap_live.html.heex
@@ -7,6 +7,7 @@
flash={@flash}
current_scope={@current_scope}
active_page="weathermap"
+ unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<.header>
diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex
index eef575c1..1d5b505e 100644
--- a/lib/towerops_web/user_auth.ex
+++ b/lib/towerops_web/user_auth.ex
@@ -607,10 +607,9 @@ defmodule ToweropsWeb.UserAuth do
alert_count =
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
- Process.put(:unresolved_alert_count, alert_count)
-
socket =
socket
+ |> Phoenix.Component.assign(:unresolved_alert_count, alert_count)
|> maybe_subscribe_to_status_updates(organization)
|> attach_status_title_hook(organization)
|> attach_alert_count_hook(organization)
@@ -1079,8 +1078,11 @@ defmodule ToweropsWeb.UserAuth do
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
{:alert_changed, org_id}, socket when org_id == organization.id ->
count = Towerops.Alerts.count_active_alerts(organization.id)
- Process.put(:unresolved_alert_count, count)
- {:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)}
+
+ {:cont,
+ socket
+ |> Phoenix.Component.assign(:_alert_count_tick, count)
+ |> Phoenix.Component.assign(:unresolved_alert_count, count)}
_message, socket ->
{:cont, socket}
diff --git a/test/snmpkit/snmp_lib/error_handler_test.exs b/test/snmpkit/snmp_lib/error_handler_test.exs
index 78b86d10..0272a03f 100644
--- a/test/snmpkit/snmp_lib/error_handler_test.exs
+++ b/test/snmpkit/snmp_lib/error_handler_test.exs
@@ -216,8 +216,9 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
# Circuit should be open
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
- # Wait for recovery timeout
- Process.sleep(110)
+ # Wait for recovery timeout using a message-based timer
+ Process.send_after(self(), :recovery_elapsed, 110)
+ assert_receive :recovery_elapsed, 1000
# Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end
diff --git a/test/towerops/api_tokens/api_token_test.exs b/test/towerops/api_tokens/api_token_test.exs
index b209e626..180c3680 100644
--- a/test/towerops/api_tokens/api_token_test.exs
+++ b/test/towerops/api_tokens/api_token_test.exs
@@ -6,7 +6,7 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
alias Towerops.ApiTokens.ApiToken
- describe "changeset/2" do
+ describe "create_changeset/4 and update_changeset/2" do
setup do
user = user_fixture()
organization = organization_fixture(user.id)
@@ -18,136 +18,124 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
organization: organization
} do
attrs = %{
- organization_id: organization.id,
- user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
- test "valid changeset without user_id", %{organization: organization} do
+ test "valid changeset without user_id", %{organization: organization, user: _user} do
attrs = %{
- organization_id: organization.id,
name: "Test Token",
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, nil, attrs)
assert changeset.valid?
end
- test "valid changeset with expires_at", %{organization: organization} do
+ test "valid changeset with expires_at", %{organization: organization, user: user} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{
- organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
expires_at: expires_at
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
- test "valid changeset with last_used_at", %{organization: organization} do
+ test "valid changeset with last_used_at", %{organization: organization, user: user} do
last_used_at = DateTime.utc_now()
attrs = %{
- organization_id: organization.id,
name: "Test Token",
token_hash: "abc123",
last_used_at: last_used_at
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
test "invalid changeset without organization_id", %{user: user} do
attrs = %{
- user_id: user.id,
name: "Test Token",
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, nil, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id
end
- test "invalid changeset without name", %{organization: organization} do
+ test "invalid changeset without name", %{organization: organization, user: user} do
attrs = %{
- organization_id: organization.id,
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
- test "invalid changeset without token_hash", %{organization: organization} do
+ test "invalid changeset without token_hash", %{organization: organization, user: user} do
attrs = %{
- organization_id: organization.id,
name: "Test Token"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).token_hash
end
- test "invalid changeset with blank name", %{organization: organization} do
+ test "invalid changeset with blank name", %{organization: organization, user: user} do
attrs = %{
- organization_id: organization.id,
name: "",
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name
end
- test "invalid changeset with name too short", %{organization: organization} do
+ test "invalid changeset with name too short", %{organization: organization, user: user} do
attrs = %{
- organization_id: organization.id,
name: "",
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
end
- test "invalid changeset with name too long", %{organization: organization} do
+ test "invalid changeset with name too long", %{organization: organization, user: user} do
long_name = String.duplicate("a", 256)
attrs = %{
- organization_id: organization.id,
name: long_name,
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid?
assert "should be at most 255 character(s)" in errors_on(changeset).name
end
- test "changeset with maximum valid name length", %{organization: organization} do
+ test "changeset with maximum valid name length", %{organization: organization, user: user} do
max_name = String.duplicate("a", 255)
attrs = %{
- organization_id: organization.id,
name: max_name,
token_hash: "abc123"
}
- changeset = ApiToken.changeset(%ApiToken{}, attrs)
+ changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid?
end
end
diff --git a/test/towerops/capacity_test.exs b/test/towerops/capacity_test.exs
index a3542068..1f5df925 100644
--- a/test/towerops/capacity_test.exs
+++ b/test/towerops/capacity_test.exs
@@ -264,6 +264,90 @@ defmodule Towerops.CapacityTest do
end
end
+ describe "get_utilization_for_interfaces/1" do
+ test "returns empty map when given empty list" do
+ assert Capacity.get_utilization_for_interfaces([]) == %{}
+ end
+
+ test "calculates utilization for multiple interfaces in one batch", %{
+ snmp_device: snmp_device,
+ interface: interface
+ } do
+ now = DateTime.utc_now()
+
+ insert_interface_stat(interface.id, %{
+ if_in_octets: 0,
+ if_out_octets: 0,
+ checked_at: DateTime.add(now, -120, :second)
+ })
+
+ insert_interface_stat(interface.id, %{
+ if_in_octets: 0,
+ if_out_octets: 1_125_000_000,
+ checked_at: DateTime.add(now, -60, :second)
+ })
+
+ # Second interface
+ interface2 =
+ %Interface{}
+ |> Interface.changeset(%{
+ snmp_device_id: snmp_device.id,
+ if_index: 2,
+ if_name: "eth1",
+ if_speed: 1_000_000_000,
+ if_type: 6,
+ configured_capacity_bps: 500_000_000,
+ capacity_source: "manual"
+ })
+ |> Repo.insert!()
+
+ insert_interface_stat(interface2.id, %{
+ if_in_octets: 0,
+ if_out_octets: 0,
+ checked_at: DateTime.add(now, -120, :second)
+ })
+
+ # 500 Mbps capacity, sending at ~250 Mbps = 50% utilization
+ insert_interface_stat(interface2.id, %{
+ if_in_octets: 0,
+ if_out_octets: 1_875_000_000,
+ checked_at: DateTime.add(now, -60, :second)
+ })
+
+ result = Capacity.get_utilization_for_interfaces([interface, interface2])
+
+ assert %{utilization_pct: pct1} = result[interface.id]
+ assert_in_delta pct1, 50.0, 1.0
+
+ assert %{utilization_pct: pct2} = result[interface2.id]
+ assert_in_delta pct2, 50.0, 1.0
+ end
+
+ test "excludes interfaces without configured capacity", %{
+ snmp_device: snmp_device,
+ interface: interface
+ } do
+ no_cap_interface =
+ %Interface{}
+ |> Interface.changeset(%{
+ snmp_device_id: snmp_device.id,
+ if_index: 3,
+ if_name: "no-cap"
+ })
+ |> Repo.insert!()
+
+ result = Capacity.get_utilization_for_interfaces([no_cap_interface, interface])
+
+ refute Map.has_key?(result, no_cap_interface.id)
+ assert Map.has_key?(result, interface.id)
+ end
+
+ test "returns empty map when no interfaces have capacity" do
+ assert Capacity.get_utilization_for_interfaces([%{id: "1", configured_capacity_bps: nil}]) == %{}
+ assert Capacity.get_utilization_for_interfaces([%{id: "2", configured_capacity_bps: 0}]) == %{}
+ end
+ end
+
describe "get_site_capacity_summary/1" do
test "aggregates capacity across interfaces at a site", %{
site: site,
diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs
index a3c5cd0b..678c1f48 100644
--- a/test/towerops/devices_test.exs
+++ b/test/towerops/devices_test.exs
@@ -41,11 +41,18 @@ defmodule Towerops.EquipmentTest do
}
@invalid_attrs %{name: nil, ip_address: nil}
- test "list_site_devices/1 returns all devices for a site", %{organization: organization, site: site} do
+ test "list_site_devices/1 returns all devices for a site with preloaded associations", %{
+ organization: organization,
+ site: site
+ } do
{:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
- assert Devices.list_site_devices(site.id) == [device]
+ result = Devices.list_site_devices(site.id)
+ assert length(result) == 1
+ assert hd(result).id == device.id
+ assert hd(result).site.id == site.id
+ assert hd(result).organization.id == organization.id
end
test "list_organization_devices/1 returns all devices for an organization", %{
diff --git a/test/towerops/organizations_test.exs b/test/towerops/organizations_test.exs
index d1a18e29..0be2cfd6 100644
--- a/test/towerops/organizations_test.exs
+++ b/test/towerops/organizations_test.exs
@@ -191,10 +191,21 @@ defmodule Towerops.OrganizationsTest do
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)
+ test "update_member_role/3 changes user role", %{organization: organization} do
+ new_user = user_fixture()
+
+ {:ok, membership} =
+ Organizations.create_membership(%{
+ organization_id: organization.id,
+ user_id: new_user.id,
+ role: :member
+ })
+
+ assert membership.role == :member
+
+ assert {:ok, updated} =
+ Organizations.update_member_role(organization.id, new_user.id, :admin)
- assert {:ok, updated} = Organizations.update_membership(membership, %{role: :admin})
assert updated.role == :admin
end
@@ -407,8 +418,15 @@ defmodule Towerops.OrganizationsTest do
# Owner can delete organization
assert Organizations.can?(membership, :delete, :organization)
- # Change to member role
- {:ok, member_membership} = Organizations.update_membership(membership, %{role: :member})
+ # Create a separate member-level membership
+ member_user = user_fixture()
+
+ {:ok, member_membership} =
+ Organizations.create_membership(%{
+ organization_id: membership.organization_id,
+ user_id: member_user.id,
+ role: :member
+ })
# Member cannot delete organization
refute Organizations.can?(member_membership, :delete, :organization)
diff --git a/test/towerops/redis_health_check_test.exs b/test/towerops/redis_health_check_test.exs
index fe664a61..8d0b0fe2 100644
--- a/test/towerops/redis_health_check_test.exs
+++ b/test/towerops/redis_health_check_test.exs
@@ -135,7 +135,11 @@ defmodule Towerops.RedisHealthCheckTest do
# Schedule recovery after a short delay — the first attempt will fail,
# then we restore the healthy state before the second attempt.
Task.async(fn ->
- Process.sleep(5)
+ receive do
+ after
+ 5 -> :ok
+ end
+
Fake.set_ping_response(Towerops.Redix, :ok)
end)
diff --git a/test/towerops/snmp/deferred_discovery_test.exs b/test/towerops/snmp/deferred_discovery_test.exs
index 87aedbea..c3c537f5 100644
--- a/test/towerops/snmp/deferred_discovery_test.exs
+++ b/test/towerops/snmp/deferred_discovery_test.exs
@@ -27,8 +27,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.fast_check(
client_opts,
fn ->
- Process.sleep(30)
- {:ok, "too slow"}
+ receive do
+ :never -> {:ok, "too slow"}
+ end
end,
timeout: 25
)
@@ -131,8 +132,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.deferred_check(
client_opts,
fn ->
- Process.sleep(30)
- {:ok, "too slow"}
+ receive do
+ :never -> {:ok, "too slow"}
+ end
end,
timeout: 25,
name: "neighbors"
@@ -177,8 +179,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
{:slow_check,
fn ->
- Process.sleep(30)
- {:ok, "slow"}
+ receive do
+ :never -> {:ok, "slow"}
+ end
end, [timeout: 25, default: []]}
]
@@ -229,8 +232,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns false when check times out" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
- Process.sleep(30)
- {:ok, [1, 3, 6, 1, 4, 1, 9]}
+ receive do
+ :never -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
+ end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
@@ -252,9 +256,11 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns :slow for delayed responses" do
expect(SnmpMock, :get, fn _target, _oid, _opts ->
- # Sleep just past the threshold to be classified as slow
- Process.sleep(35)
- {:ok, "Slow Device"}
+ # Delay just past the threshold to be classified as slow
+ receive do
+ after
+ 35 -> {:ok, "Slow Device"}
+ end
end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
diff --git a/test/towerops/snmp/poller_test.exs b/test/towerops/snmp/poller_test.exs
index 55f32c18..1b1b9084 100644
--- a/test/towerops/snmp/poller_test.exs
+++ b/test/towerops/snmp/poller_test.exs
@@ -88,8 +88,10 @@ defmodule Towerops.Snmp.PollerTest do
# Simulate a delayed response
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
- Process.sleep(1)
- {:ok, {:timeticks, 999}}
+ receive do
+ after
+ 1 -> {:ok, {:timeticks, 999}}
+ end
end)
assert {:ok, response_time} = Poller.check_device(client_opts)
diff --git a/test/towerops_web/live/mobile_qr_live_test.exs b/test/towerops_web/live/mobile_qr_live_test.exs
index e583b3b0..1029877d 100644
--- a/test/towerops_web/live/mobile_qr_live_test.exs
+++ b/test/towerops_web/live/mobile_qr_live_test.exs
@@ -71,7 +71,6 @@ defmodule ToweropsWeb.MobileQRLiveTest do
assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token)
send(view.pid, :check_completion)
- Process.sleep(50)
html = render(view)
# The template shows the QR page until @completed switches