ci: enable PostGIS in test workflows + add tests covering ~3pp more

CI: switch postgres service image to timescale/timescaledb-ha:pg17-all
which bundles PostGIS, fixing the lidar migration failure in PR and
production workflows.

Tests: ~30 new test files / extensions, ~600+ new tests, taking total
coverage from 70.42% to 73.4%. Targets included GraphQL resolver
unauthenticated paths, SNMP context queries (mempools, processors,
ip_addresses, neighbors, wireless_clients, arp_entries), worker
routing/lifecycle (alert notification, alert digest, weather sync,
job cleanup, cn_maestro, uisp, mikrotik backup, report worker),
SNMP monitoring executors (storage, interface, processor) end-to-end
via stub adapter, NetBox + Sonar sync integration paths, ConfigChanges
listing and correlator, organizations membership query, and live-view
smoke tests for org-scoped settings, integrations, maintenance, agent,
device deep paths, schedules. Property tests used for query
composition and pure helpers where natural.
This commit is contained in:
Graham McIntire 2026-05-05 10:35:22 -05:00
parent 23be5c2ec2
commit fde6158251
33 changed files with 4755 additions and 9 deletions

View file

@ -25,7 +25,7 @@ jobs:
services:
postgres:
image: timescale/timescaledb:latest-pg17
image: timescale/timescaledb-ha:pg17-all
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres

View file

@ -19,7 +19,7 @@ jobs:
services:
postgres:
image: timescale/timescaledb:latest-pg17
image: timescale/timescaledb-ha:pg17-all
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres

View file

@ -0,0 +1,134 @@
defmodule Towerops.Alerts.AlertQueryTest do
use Towerops.DataCase, async: true
use ExUnitProperties
import Towerops.DevicesFixtures
alias Towerops.Alerts.Alert
alias Towerops.Alerts.AlertQuery
alias Towerops.Repo
setup do
device = device_fixture()
%{device: device, organization_id: device.organization_id}
end
describe "base/0" do
test "returns the Alert schema as a queryable" do
assert AlertQuery.base() == Alert
end
end
describe "for_organization/2" do
test "filters to a single organization", %{device: device, organization_id: org_id} do
keep = insert_alert!(device.id, org_id, "device_down")
other_device = device_fixture()
_other_org = insert_alert!(other_device.id, other_device.organization_id, "device_down")
[result] = AlertQuery.base() |> AlertQuery.for_organization(org_id) |> Repo.all()
assert result.id == keep.id
end
test "is composable with other filters", %{device: device, organization_id: org_id} do
a = insert_alert!(device.id, org_id, "device_down")
_b = insert_alert!(device.id, org_id, "device_up")
[result] =
AlertQuery.base()
|> AlertQuery.for_organization(org_id)
|> AlertQuery.of_type("device_down")
|> Repo.all()
assert result.id == a.id
end
end
describe "for_device/2" do
test "filters to a single device", %{device: device, organization_id: org_id} do
other_device = device_fixture()
keep = insert_alert!(device.id, org_id, "device_down")
_drop = insert_alert!(other_device.id, other_device.organization_id, "device_down")
[result] = AlertQuery.base() |> AlertQuery.for_device(device.id) |> Repo.all()
assert result.id == keep.id
end
end
describe "of_type/2" do
test "filters by alert_type", %{device: device, organization_id: org_id} do
down = insert_alert!(device.id, org_id, "device_down")
_up = insert_alert!(device.id, org_id, "device_up")
[result] = AlertQuery.base() |> AlertQuery.of_type("device_down") |> Repo.all()
assert result.id == down.id
end
end
describe "unresolved/1 and resolved/1" do
test "partition alerts by resolved_at presence", %{device: device, organization_id: org_id} do
open = insert_alert!(device.id, org_id, "device_down")
closed =
insert_alert!(device.id, org_id, "device_down", resolved_at: DateTime.truncate(DateTime.utc_now(), :second))
assert [%{id: id1}] = AlertQuery.base() |> AlertQuery.unresolved() |> Repo.all()
assert id1 == open.id
assert [%{id: id2}] = AlertQuery.base() |> AlertQuery.resolved() |> Repo.all()
assert id2 == closed.id
end
end
describe "with_severity/2" do
test "filters by severity", %{device: device, organization_id: org_id} do
crit = insert_alert!(device.id, org_id, "device_down", severity: 1)
_warn = insert_alert!(device.id, org_id, "device_down", severity: 2)
[result] = AlertQuery.base() |> AlertQuery.with_severity(1) |> Repo.all()
assert result.id == crit.id
end
end
describe "property: composed filters always return a subset" do
property "organization + type + unresolved is always a subset of for_organization", %{
device: device,
organization_id: org_id
} do
_ = insert_alert!(device.id, org_id, "device_down")
_ = insert_alert!(device.id, org_id, "device_up")
_ = insert_alert!(device.id, org_id, "device_down", resolved_at: DateTime.truncate(DateTime.utc_now(), :second))
check all(alert_type <- StreamData.member_of(["device_down", "device_up", "check_ping"]), max_runs: 25) do
all_for_org_ids =
AlertQuery.base()
|> AlertQuery.for_organization(org_id)
|> Repo.all()
|> MapSet.new(& &1.id)
narrowed_ids =
AlertQuery.base()
|> AlertQuery.for_organization(org_id)
|> AlertQuery.of_type(alert_type)
|> AlertQuery.unresolved()
|> Repo.all()
|> MapSet.new(& &1.id)
assert MapSet.subset?(narrowed_ids, all_for_org_ids)
end
end
end
defp insert_alert!(device_id, organization_id, alert_type, extra \\ []) do
attrs =
Enum.into(extra, %{
device_id: device_id,
organization_id: organization_id,
alert_type: alert_type,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second)
})
%Alert{}
|> Alert.changeset(attrs)
|> Repo.insert!()
end
end

View file

@ -0,0 +1,44 @@
defmodule Towerops.ConfigChanges.ConfigChangeEventTest do
use ExUnit.Case, async: true
alias Towerops.ConfigChanges.ConfigChangeEvent
describe "changeset/2" do
test "valid with required fields" do
attrs = %{
device_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
changed_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert ConfigChangeEvent.changeset(%ConfigChangeEvent{}, attrs).valid?
end
test "rejects missing required fields" do
changeset = ConfigChangeEvent.changeset(%ConfigChangeEvent{}, %{})
keys = Keyword.keys(changeset.errors)
assert :device_id in keys
assert :organization_id in keys
assert :changed_at in keys
end
test "accepts optional fields" do
attrs = %{
device_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
changed_at: DateTime.truncate(DateTime.utc_now(), :second),
diff_summary: "+ /ip firewall ...",
sections_changed: ["firewall", "interface"],
change_size: 12,
backup_before_id: Ecto.UUID.generate(),
backup_after_id: Ecto.UUID.generate()
}
changeset = ConfigChangeEvent.changeset(%ConfigChangeEvent{}, attrs)
assert changeset.valid?
assert changeset.changes.sections_changed == ["firewall", "interface"]
assert changeset.changes.change_size == 12
end
end
end

View file

@ -0,0 +1,255 @@
defmodule Towerops.ConfigChanges.CorrelatorIntegrationTest do
@moduledoc """
Integration tests for `Towerops.ConfigChanges.Correlator` covering the
database-backed code paths: `correlate/1` and `correlate_recent/2`.
Pure-function unit tests live in `correlator_test.exs`.
"""
use Towerops.DataCase, async: true
alias Towerops.AccountsFixtures
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.ConfigChanges.Correlator
alias Towerops.DevicesFixtures
alias Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Preseem.SubscriberMetric
defp setup_org_and_device do
user = AccountsFixtures.user_fixture()
org = OrganizationsFixtures.organization_fixture(user.id)
site = OrganizationsFixtures.site_fixture(org.id)
device = DevicesFixtures.device_fixture(org.id, site.id)
{org, device}
end
defp insert_access_point(org_id, device_id) do
%AccessPoint{}
|> AccessPoint.changeset(%{
organization_id: org_id,
preseem_id: "ap-#{System.unique_integer([:positive])}",
name: "Test AP",
device_id: device_id,
match_confidence: "auto_ip"
})
|> Repo.insert!()
end
defp insert_metric(ap_id, recorded_at, attrs) do
base = %{
preseem_access_point_id: ap_id,
recorded_at: DateTime.truncate(recorded_at, :second)
}
%SubscriberMetric{}
|> SubscriberMetric.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp insert_event(org_id, device_id, changed_at, sections \\ []) do
%ConfigChangeEvent{}
|> ConfigChangeEvent.changeset(%{
organization_id: org_id,
device_id: device_id,
changed_at: DateTime.truncate(changed_at, :second),
sections_changed: sections
})
|> Repo.insert!()
end
describe "correlate/1" do
test "returns :no_data when no Preseem access points are matched to the device" do
{org, device} = setup_org_and_device()
changed_at = DateTime.utc_now()
event = insert_event(org.id, device.id, changed_at)
assert {:ok, :no_data} = Correlator.correlate(event)
end
test "returns :no_data when no metrics exist in either window" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
_ = ap
event = insert_event(org.id, device.id, DateTime.utc_now())
assert {:ok, :no_data} = Correlator.correlate(event)
end
test "returns :no_data when only the pre-window has metrics" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
changed_at = DateTime.utc_now()
event = insert_event(org.id, device.id, changed_at)
pre_at = DateTime.add(changed_at, -3600, :second)
insert_metric(ap.id, pre_at, %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
assert {:ok, :no_data} = Correlator.correlate(event)
end
test "returns :no_impact when post metrics are similar to pre metrics" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
changed_at = DateTime.utc_now()
event = insert_event(org.id, device.id, changed_at)
pre_at = DateTime.add(changed_at, -3600, :second)
post_at = DateTime.add(changed_at, 3600, :second)
stable = %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
}
insert_metric(ap.id, pre_at, stable)
insert_metric(ap.id, post_at, stable)
assert {:ok, :no_impact} = Correlator.correlate(event)
end
test "creates a suspect_config_change insight when latency degrades significantly" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
changed_at = DateTime.utc_now()
event = insert_event(org.id, device.id, changed_at, ["firewall"])
pre_at = DateTime.add(changed_at, -3600, :second)
post_at = DateTime.add(changed_at, 3600, :second)
insert_metric(ap.id, pre_at, %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
insert_metric(ap.id, post_at, %{
avg_latency: 30.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
assert {:ok, %Insight{} = insight} = Correlator.correlate(event)
assert insight.type == "suspect_config_change"
assert insight.urgency in ["info", "warning", "critical"]
assert insight.source == "system"
assert insight.channel == "proactive"
assert insight.organization_id == org.id
assert insight.device_id == device.id
assert insight.metadata["change_event_id"] == event.id
assert insight.metadata["sections_changed"] == ["firewall"]
assert insight.description =~ "After config change"
end
test "throughput drop alone is enough to trigger an insight" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
changed_at = DateTime.utc_now()
event = insert_event(org.id, device.id, changed_at)
pre_at = DateTime.add(changed_at, -3600, :second)
post_at = DateTime.add(changed_at, 3600, :second)
insert_metric(ap.id, pre_at, %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
insert_metric(ap.id, post_at, %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 50.0
})
assert {:ok, %Insight{}} = Correlator.correlate(event)
end
test "deduplicates: a second correlate for the same device returns :duplicate" do
{org, device} = setup_org_and_device()
ap = insert_access_point(org.id, device.id)
changed_at = DateTime.utc_now()
pre_at = DateTime.add(changed_at, -3600, :second)
post_at = DateTime.add(changed_at, 3600, :second)
insert_metric(ap.id, pre_at, %{
avg_latency: 10.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
insert_metric(ap.id, post_at, %{
avg_latency: 30.0,
avg_jitter: 1.0,
avg_loss: 0.0,
avg_throughput: 100.0
})
event1 = insert_event(org.id, device.id, changed_at)
event2 = insert_event(org.id, device.id, changed_at)
assert {:ok, %Insight{}} = Correlator.correlate(event1)
assert {:ok, :duplicate} = Correlator.correlate(event2)
end
end
describe "correlate_recent/2" do
test "returns empty list when organization has no events" do
user = AccountsFixtures.user_fixture()
org = OrganizationsFixtures.organization_fixture(user.id)
assert {:ok, []} = Correlator.correlate_recent(org.id)
end
test "correlates events newer than the cutoff and skips older ones" do
{org, device} = setup_org_and_device()
_ap = insert_access_point(org.id, device.id)
# Recent event (within default 24h window) — no AP metrics, so :no_data
_recent = insert_event(org.id, device.id, DateTime.utc_now())
# Older event (48h ago) — should be filtered out
old_at = DateTime.add(DateTime.utc_now(), -48 * 3600, :second)
_old = insert_event(org.id, device.id, old_at)
{:ok, results} = Correlator.correlate_recent(org.id, hours_back: 24)
assert length(results) == 1
assert [{:ok, :no_data}] = results
end
test "honors the hours_back option" do
{org, device} = setup_org_and_device()
old_at = DateTime.add(DateTime.utc_now(), -48 * 3600, :second)
_ = insert_event(org.id, device.id, old_at)
# Window large enough to include the 48h-old event
{:ok, results} = Correlator.correlate_recent(org.id, hours_back: 72)
assert length(results) == 1
end
test "scopes to the given organization" do
{org_a, device_a} = setup_org_and_device()
{org_b, device_b} = setup_org_and_device()
_ = insert_event(org_a.id, device_a.id, DateTime.utc_now())
_ = insert_event(org_b.id, device_b.id, DateTime.utc_now())
{:ok, results_a} = Correlator.correlate_recent(org_a.id)
assert length(results_a) == 1
end
end
end

View file

@ -0,0 +1,126 @@
defmodule Towerops.ConfigChangesListingTest do
@moduledoc """
Tests for listing functions on `Towerops.ConfigChanges` (the pure
detect-section helper is covered separately in `config_changes_test.exs`).
"""
use Towerops.DataCase, async: true
import Towerops.DevicesFixtures
alias Towerops.ConfigChanges
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.Repo
setup do
device = device_fixture()
%{device: device, organization_id: device.organization_id, site_id: device.site_id}
end
describe "list_device_changes/2" do
test "returns events for the device, most recent first", %{device: device, organization_id: org_id} do
_ = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z])
newer = insert_event!(device.id, org_id, ~U[2026-02-01 00:00:00Z])
result = ConfigChanges.list_device_changes(device.id)
assert hd(result).id == newer.id
end
test "honors :limit", %{device: device, organization_id: org_id} do
Enum.each(1..3, fn i ->
insert_event!(device.id, org_id, DateTime.add(~U[2026-01-01 00:00:00Z], i, :day))
end)
assert length(ConfigChanges.list_device_changes(device.id, limit: 2)) == 2
end
test "honors :after and :before", %{device: device, organization_id: org_id} do
a = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z])
b = insert_event!(device.id, org_id, ~U[2026-06-01 00:00:00Z])
c = insert_event!(device.id, org_id, ~U[2026-12-01 00:00:00Z])
result =
ConfigChanges.list_device_changes(device.id,
after: ~U[2026-03-01 00:00:00Z],
before: ~U[2026-09-01 00:00:00Z]
)
ids = MapSet.new(result, & &1.id)
assert MapSet.equal?(ids, MapSet.new([b.id]))
refute MapSet.member?(ids, a.id)
refute MapSet.member?(ids, c.id)
end
test "returns [] for unknown device" do
assert ConfigChanges.list_device_changes(Ecto.UUID.generate()) == []
end
end
describe "list_org_changes/2" do
test "returns events for the org, with optional preload", %{device: device, organization_id: org_id} do
_ = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second))
[event] = ConfigChanges.list_org_changes(org_id, preload: [:device])
assert Ecto.assoc_loaded?(event.device)
assert event.device.id == device.id
end
test "honors :limit and :before", %{device: device, organization_id: org_id} do
_old = insert_event!(device.id, org_id, ~U[2026-01-01 00:00:00Z])
_new = insert_event!(device.id, org_id, ~U[2026-12-31 00:00:00Z])
result = ConfigChanges.list_org_changes(org_id, limit: 1, before: ~U[2026-06-01 00:00:00Z])
assert length(result) == 1
end
end
describe "list_site_changes/2" do
test "returns events for devices at the site", %{device: device, organization_id: org_id, site_id: site_id} do
_ = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second))
result = ConfigChanges.list_site_changes(site_id)
assert length(result) == 1
end
test "returns [] for site with no devices", %{organization_id: _org_id} do
# Build a site with no devices
{:ok, empty_site} =
Towerops.Sites.create_site(%{
name: "Empty Site #{System.unique_integer([:positive])}",
organization_id: device_fixture().organization_id
})
assert ConfigChanges.list_site_changes(empty_site.id) == []
end
end
describe "get_change_event!/1 and get_change_event_with_preloads!/1" do
test "returns the event by id", %{device: device, organization_id: org_id} do
e = insert_event!(device.id, org_id, DateTime.truncate(DateTime.utc_now(), :second))
assert %ConfigChangeEvent{id: id} = ConfigChanges.get_change_event!(e.id)
assert id == e.id
assert %ConfigChangeEvent{} = preloaded = ConfigChanges.get_change_event_with_preloads!(e.id)
assert Ecto.assoc_loaded?(preloaded.device)
end
test "raises for unknown id" do
assert_raise Ecto.NoResultsError, fn ->
ConfigChanges.get_change_event!(Ecto.UUID.generate())
end
end
end
defp insert_event!(device_id, org_id, changed_at) do
%ConfigChangeEvent{}
|> ConfigChangeEvent.changeset(%{
device_id: device_id,
organization_id: org_id,
changed_at: changed_at,
diff_summary: "summary",
sections_changed: ["firewall"],
change_size: 1
})
|> Repo.insert!()
end
end

View file

@ -0,0 +1,212 @@
defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutorTest do
@moduledoc """
End-to-end tests for `SnmpInterfaceExecutor.execute/1`, driving the full
`with` chain (interface lookup, device + snmp_device preload, SNMP poll,
status mapping, output formatting) using `Towerops.Snmp.SnmpMock`.
"""
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Monitoring
alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor
alias Towerops.Snmp
alias Towerops.Snmp.Interface
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
setup do
user = user_fixture()
{: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, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.0.2.20",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
device = Towerops.Devices.get_device!(device.id)
snmp_device =
%Snmp.Device{}
|> Snmp.Device.changeset(%{
device_id: device.id,
sys_name: "test-router",
sys_descr: "Router OS"
})
|> Repo.insert!()
%{device: device, snmp_device: snmp_device, organization: organization}
end
defp insert_interface(snmp_device, attrs) do
base = %{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_descr: "Ethernet 0",
if_admin_status: "up"
}
%Interface{}
|> Interface.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp build_check(device, source_id) do
{:ok, check} =
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: "Interface up?",
check_type: "snmp_interface",
source_type: "auto_discovery",
source_id: source_id,
config: %{}
})
check
end
describe "execute/1 success path" do
test "returns OK status when ifOperStatus = up (1)", %{
device: device,
snmp_device: snmp_device
} do
interface =
insert_interface(snmp_device, %{if_index: 2, if_name: "eth1", if_alias: "WAN"})
check = build_check(device, interface.id)
expect(SnmpMock, :get, fn _target, oid, _opts ->
# Expect the IF-MIB ifOperStatus.<index> OID
assert to_string(oid) == "1.3.6.1.2.1.2.2.1.8.2"
{:ok, {:integer, 1}}
end)
assert {:ok, response} = SnmpInterfaceExecutor.execute(check)
assert response.value == 1
assert response.status == 0
assert response.output =~ "WAN"
assert response.output =~ "Up"
assert is_integer(response.response_time_ms)
end
test "admin-down interface that is operationally down is OK", %{
device: device,
snmp_device: snmp_device
} do
interface =
insert_interface(snmp_device, %{if_index: 3, if_admin_status: "down"})
check = build_check(device, interface.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:integer, 2}}
end)
assert {:ok, response} = SnmpInterfaceExecutor.execute(check)
assert response.status == 0
assert response.output =~ "Down"
end
test "down interface that should be up is CRITICAL", %{
device: device,
snmp_device: snmp_device
} do
interface = insert_interface(snmp_device, %{if_index: 4, if_admin_status: "up"})
check = build_check(device, interface.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:integer, 2}}
end)
assert {:ok, response} = SnmpInterfaceExecutor.execute(check)
assert response.status == 2
end
end
describe "execute/1 error paths" do
test "returns error when interface record is missing", %{device: device} do
check = build_check(device, Ecto.UUID.generate())
assert {:error, msg} = SnmpInterfaceExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "Interface not found"
end
test "returns error when device has no SNMP device record", %{
organization: organization,
snmp_device: snmp_device
} do
interface = insert_interface(snmp_device, %{if_index: 5})
{:ok, site} =
Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id})
{:ok, bare_device} =
Towerops.Devices.create_device(%{
name: "Bare",
ip_address: "192.0.2.51",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
{:ok, check} =
Monitoring.create_check(%{
organization_id: organization.id,
device_id: bare_device.id,
name: "Iface",
check_type: "snmp_interface",
source_type: "auto_discovery",
source_id: interface.id,
config: %{}
})
assert {:error, msg} = SnmpInterfaceExecutor.execute(check)
assert msg =~ "does not have SNMP configured"
end
test "propagates SNMP error", %{device: device, snmp_device: snmp_device} do
interface = insert_interface(snmp_device, %{if_index: 6})
check = build_check(device, interface.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:error, :timeout}
end)
assert {:error, :timeout} = SnmpInterfaceExecutor.execute(check)
end
test "returns error when SNMP returns non-integer value", %{
device: device,
snmp_device: snmp_device
} do
interface = insert_interface(snmp_device, %{if_index: 8})
check = build_check(device, interface.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:octet_string, "not-an-integer"}}
end)
assert {:error, msg} = SnmpInterfaceExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "Unexpected value type"
end
end
end

View file

@ -0,0 +1,231 @@
defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutorTest do
@moduledoc """
End-to-end tests for `SnmpProcessorExecutor.execute/1`, driving the full
`with` chain (processor lookup, device + snmp_device preload, SNMP poll,
status mapping, output formatting) using `Towerops.Snmp.SnmpMock`.
"""
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Monitoring
alias Towerops.Monitoring.Executors.SnmpProcessorExecutor
alias Towerops.Snmp
alias Towerops.Snmp.Processor
alias Towerops.Snmp.SnmpMock
setup :verify_on_exit!
setup do
user = user_fixture()
{: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, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.0.2.30",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
device = Towerops.Devices.get_device!(device.id)
snmp_device =
%Snmp.Device{}
|> Snmp.Device.changeset(%{
device_id: device.id,
sys_name: "test-device",
sys_descr: "Test"
})
|> Repo.insert!()
%{device: device, snmp_device: snmp_device, organization: organization}
end
defp insert_processor(snmp_device, attrs) do
base = %{
snmp_device_id: snmp_device.id,
processor_index: "1",
processor_type: "hr_processor",
description: "CPU 0"
}
%Processor{}
|> Processor.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp build_check(device, source_id) do
{:ok, check} =
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: "CPU load",
check_type: "snmp_processor",
source_type: "auto_discovery",
source_id: source_id,
config: %{}
})
check
end
describe "execute/1 success path" do
test "returns OK status for low load, hr_processor", %{
device: device,
snmp_device: snmp_device
} do
processor = insert_processor(snmp_device, %{processor_index: "1"})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, oid, _opts ->
# hr_processor uses 1.3.6.1.2.1.25.3.3.1.2.<index>
assert to_string(oid) == "1.3.6.1.2.1.25.3.3.1.2.1"
{:ok, {:integer, 25}}
end)
assert {:ok, response} = SnmpProcessorExecutor.execute(check)
assert response.value == 25.0
assert response.status == 0
assert response.output =~ "CPU 0"
assert response.output =~ "25.0%"
assert is_integer(response.response_time_ms)
end
test "returns WARNING status for 80-90% load", %{
device: device,
snmp_device: snmp_device
} do
processor = insert_processor(snmp_device, %{processor_index: "2"})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:integer, 85}}
end)
assert {:ok, response} = SnmpProcessorExecutor.execute(check)
assert response.value == 85.0
assert response.status == 1
end
test "returns CRITICAL status for >= 90% load", %{
device: device,
snmp_device: snmp_device
} do
processor = insert_processor(snmp_device, %{processor_index: "3"})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:integer, 95}}
end)
assert {:ok, response} = SnmpProcessorExecutor.execute(check)
assert response.value == 95.0
assert response.status == 2
end
test "uses Cisco OID for cisco_cpu type", %{
device: device,
snmp_device: snmp_device
} do
processor =
insert_processor(snmp_device, %{
processor_index: "7",
processor_type: "cisco_cpu",
description: "Cisco CPU"
})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, oid, _opts ->
assert to_string(oid) == "1.3.6.1.4.1.9.9.109.1.1.1.1.5.7"
{:ok, {:integer, 10}}
end)
assert {:ok, response} = SnmpProcessorExecutor.execute(check)
assert response.value == 10.0
assert response.status == 0
end
end
describe "execute/1 error paths" do
test "returns error when processor record is missing", %{device: device} do
check = build_check(device, Ecto.UUID.generate())
assert {:error, msg} = SnmpProcessorExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "Processor not found"
end
test "returns error when device has no SNMP device record", %{
organization: organization,
snmp_device: snmp_device
} do
processor = insert_processor(snmp_device, %{processor_index: "5"})
{:ok, site} =
Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id})
{:ok, bare_device} =
Towerops.Devices.create_device(%{
name: "Bare",
ip_address: "192.0.2.52",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
{:ok, check} =
Monitoring.create_check(%{
organization_id: organization.id,
device_id: bare_device.id,
name: "CPU",
check_type: "snmp_processor",
source_type: "auto_discovery",
source_id: processor.id,
config: %{}
})
assert {:error, msg} = SnmpProcessorExecutor.execute(check)
assert msg =~ "does not have SNMP configured"
end
test "propagates SNMP error", %{device: device, snmp_device: snmp_device} do
processor = insert_processor(snmp_device, %{processor_index: "6"})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:error, :timeout}
end)
assert {:error, :timeout} = SnmpProcessorExecutor.execute(check)
end
test "returns error when SNMP returns non-numeric value", %{
device: device,
snmp_device: snmp_device
} do
processor = insert_processor(snmp_device, %{processor_index: "8"})
check = build_check(device, processor.id)
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, {:octet_string, "garbage"}}
end)
assert {:error, msg} = SnmpProcessorExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "Unexpected value type"
end
end
end

View file

@ -0,0 +1,221 @@
defmodule Towerops.Monitoring.Executors.SnmpStorageExecutorTest do
@moduledoc """
End-to-end tests for `SnmpStorageExecutor.execute/1`.
Drives the full `with` chain fetching the storage row, the device with its
snmp_device, building SNMP options, and polling using the configured
`Towerops.Snmp.SnmpMock` (set as `:snmp_adapter` in test config) to stub
SNMP responses without touching the network.
"""
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Monitoring
alias Towerops.Monitoring.Executors.SnmpStorageExecutor
alias Towerops.Snmp
alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.Storage
setup :verify_on_exit!
setup do
user = user_fixture()
{: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, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.0.2.10",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
device = Towerops.Devices.get_device!(device.id)
snmp_device =
%Snmp.Device{}
|> Snmp.Device.changeset(%{
device_id: device.id,
sys_name: "test-host",
sys_descr: "Linux Test"
})
|> Repo.insert!()
%{device: device, snmp_device: snmp_device, organization: organization}
end
defp insert_storage(snmp_device, attrs \\ %{}) do
base = %{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/",
device_name: "/dev/sda1"
}
%Storage{}
|> Storage.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp build_check(device, source_id) do
{:ok, check} =
Monitoring.create_check(%{
organization_id: device.organization_id,
device_id: device.id,
name: "Disk usage",
check_type: "snmp_storage",
source_type: "auto_discovery",
source_id: source_id,
config: %{}
})
check
end
# The Client wraps adapter results: get_multiple returns {:ok, %{oid => {:type, value}}}.
defp typed_map(oids, values) do
oids
|> Enum.zip(values)
|> Map.new(fn {oid, val} -> {oid, {:integer, val}} end)
end
describe "execute/1 success path" do
test "returns standardized response with usage percent and OK status", %{
device: device,
snmp_device: snmp_device
} do
storage = insert_storage(snmp_device)
check = build_check(device, storage.id)
# 50% usage: 4096 alloc * 1000 size, 4096 alloc * 500 used → 50%
expect(SnmpMock, :get_multiple, fn _target, oids, _opts ->
assert length(oids) == 3
[alloc_oid, size_oid, used_oid] = oids
assert String.ends_with?(to_string(alloc_oid), ".4.1")
assert String.ends_with?(to_string(size_oid), ".5.1")
assert String.ends_with?(to_string(used_oid), ".6.1")
{:ok, typed_map(oids, [4096, 1000, 500])}
end)
assert {:ok, response} = SnmpStorageExecutor.execute(check)
assert response.value == 50.0
assert response.status == 0
assert is_binary(response.output)
assert String.contains?(response.output, "50.0%")
assert is_integer(response.response_time_ms)
assert response.response_time_ms >= 0
end
test "returns CRITICAL status when usage is at or above 95%", %{
device: device,
snmp_device: snmp_device
} do
storage = insert_storage(snmp_device, %{storage_index: 2, description: "/var"})
check = build_check(device, storage.id)
expect(SnmpMock, :get_multiple, fn _target, oids, _opts ->
{:ok, typed_map(oids, [1, 100, 96])}
end)
assert {:ok, response} = SnmpStorageExecutor.execute(check)
assert response.value == 96.0
assert response.status == 2
end
test "returns WARNING status when usage is between 85-95%", %{
device: device,
snmp_device: snmp_device
} do
storage = insert_storage(snmp_device, %{storage_index: 3, description: "/home"})
check = build_check(device, storage.id)
expect(SnmpMock, :get_multiple, fn _target, oids, _opts ->
{:ok, typed_map(oids, [1, 100, 90])}
end)
assert {:ok, response} = SnmpStorageExecutor.execute(check)
assert response.value == 90.0
assert response.status == 1
end
end
describe "execute/1 error paths" do
test "returns error when storage record is missing", %{device: device} do
check = build_check(device, Ecto.UUID.generate())
assert {:error, msg} = SnmpStorageExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "Storage not found"
end
test "returns error when device has no SNMP device record", %{
organization: organization,
snmp_device: snmp_device
} do
# Storage row attached to the existing snmp_device, but the check
# points at a *different* device that has no snmp_device.
storage = insert_storage(snmp_device, %{storage_index: 7})
{:ok, site} =
Towerops.Sites.create_site(%{name: "No SNMP Site", organization_id: organization.id})
{:ok, bare_device} =
Towerops.Devices.create_device(%{
name: "Bare",
ip_address: "192.0.2.50",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
{:ok, check} =
Monitoring.create_check(%{
organization_id: organization.id,
device_id: bare_device.id,
name: "Disk",
check_type: "snmp_storage",
source_type: "auto_discovery",
source_id: storage.id,
config: %{}
})
assert {:error, msg} = SnmpStorageExecutor.execute(check)
assert is_binary(msg)
assert msg =~ "does not have SNMP configured"
end
test "propagates SNMP error when both batched and sequential paths fail", %{
device: device,
snmp_device: snmp_device
} do
storage = insert_storage(snmp_device, %{storage_index: 9})
check = build_check(device, storage.id)
# Batched path fails; Client falls back to sequential get/3 per OID.
expect(SnmpMock, :get_multiple, fn _target, _oids, _opts ->
{:error, :timeout}
end)
expect(SnmpMock, :get, 3, fn _target, _oid, _opts ->
{:error, :timeout}
end)
assert {:error, _reason} = SnmpStorageExecutor.execute(check)
end
end
end

View file

@ -0,0 +1,277 @@
defmodule Towerops.NetBox.SyncIntegrationTest do
@moduledoc """
Integration tests for `Towerops.NetBox.Sync.sync_organization/1`.
Pure-function unit tests live in `sync_test.exs`.
"""
use Towerops.DataCase, async: true
alias Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Integrations
alias Towerops.NetBox.Client
alias Towerops.NetBox.Sync
alias Towerops.OrganizationsFixtures
alias Towerops.Sites
@url "https://netbox.example.com"
@token "test-token"
defp setup_integration(extra_creds \\ %{}) do
user = AccountsFixtures.user_fixture()
org = OrganizationsFixtures.organization_fixture(user.id)
creds =
Map.merge(
%{"url" => @url, "api_token" => @token},
extra_creds
)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "netbox",
enabled: true,
credentials: creds
})
{org, integration}
end
defp netbox_site(name, attrs \\ %{}) do
Map.merge(
%{
"id" => System.unique_integer([:positive]),
"name" => name,
"latitude" => nil,
"longitude" => nil
},
attrs
)
end
defp netbox_device(name, attrs) do
Map.merge(
%{
"id" => System.unique_integer([:positive]),
"name" => name,
"primary_ip4" => %{"address" => "10.0.0.#{:rand.uniform(254)}/24"},
"site" => nil,
"role" => nil,
"status" => %{"value" => "active"},
"serial" => nil
},
attrs
)
end
describe "sync_organization/1 — disabled flags" do
test "returns zero counts and success when neither sync flag is set" do
{_org, integration} = setup_integration()
# No HTTP stub needed — neither branch should make a call.
assert {:ok, %{sites: 0, devices: 0}} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload!(integration)
assert reloaded.last_sync_status == "success"
assert reloaded.last_sync_message =~ "Synced 0 sites, 0 devices"
end
end
describe "sync_organization/1 — sites sync" do
test "creates new sites returned by NetBox (no lat/long)" do
{org, integration} = setup_integration(%{"sync_sites" => true})
Req.Test.stub(Client, fn conn ->
assert conn.request_path == "/api/dcim/sites/"
Req.Test.json(conn, %{
"results" => [netbox_site("HQ"), netbox_site("Edge")]
})
end)
assert {:ok, %{sites: 2, devices: 0}} = Sync.sync_organization(integration)
sites = Sites.list_organization_sites(org.id)
names = sites |> Enum.map(& &1.name) |> Enum.sort()
assert names == ["Edge", "HQ"]
end
test "updates an existing site (matched by lowercased name)" do
{org, integration} = setup_integration(%{"sync_sites" => "true"})
existing = OrganizationsFixtures.site_fixture(org.id, %{name: "Hq"})
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{"results" => [netbox_site("HQ")]})
end)
assert {:ok, %{sites: 1}} = Sync.sync_organization(integration)
reloaded = Sites.get_site!(existing.id)
assert reloaded.id == existing.id
assert reloaded.name == "HQ"
end
test "passes tag filter to the NetBox client when configured" do
{_org, integration} =
setup_integration(%{"sync_sites" => true, "tag_filter" => "production"})
test_pid = self()
Req.Test.stub(Client, fn conn ->
send(test_pid, {:query_string, conn.query_string})
Req.Test.json(conn, %{"results" => []})
end)
assert {:ok, %{sites: 0}} = Sync.sync_organization(integration)
assert_received {:query_string, qs}
assert qs =~ "tag=production"
end
test "sites HTTP 401 propagates and updates sync status to failed" do
{_org, integration} = setup_integration(%{"sync_sites" => true})
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.json(%{})
end)
assert {:error, :unauthorized} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload!(integration)
assert reloaded.last_sync_status == "failed"
assert reloaded.last_sync_message =~ "Authentication failed"
end
end
describe "sync_organization/1 — devices sync" do
test "creates devices and matches sites by name" do
{org, integration} =
setup_integration(%{"sync_sites" => true, "sync_devices" => true})
site_payload = netbox_site("DC1")
device_payload =
netbox_device("router-01", %{
"primary_ip4" => %{"address" => "10.0.0.1/24"},
"site" => %{"name" => "DC1"},
"role" => %{"name" => "core-router"},
"status" => %{"value" => "active"}
})
Req.Test.stub(Client, fn conn ->
case conn.request_path do
"/api/dcim/sites/" ->
Req.Test.json(conn, %{"results" => [site_payload]})
"/api/dcim/devices/" ->
Req.Test.json(conn, %{"results" => [device_payload]})
end
end)
assert {:ok, %{sites: 1, devices: 1}} = Sync.sync_organization(integration)
[device] = Devices.list_organization_devices(org.id)
assert device.name == "router-01"
assert to_string(device.ip_address) == "10.0.0.1"
assert device.device_role == "core-router"
assert device.status == :up
assert device.site_id
end
test "device-only sync skips sites endpoint" do
{_org, integration} = setup_integration(%{"sync_devices" => true})
Req.Test.stub(Client, fn conn ->
assert conn.request_path == "/api/dcim/devices/"
Req.Test.json(conn, %{"results" => []})
end)
assert {:ok, %{sites: 0, devices: 0}} = Sync.sync_organization(integration)
end
test "passes role/site/tag filters to the device endpoint" do
{_org, integration} =
setup_integration(%{
"sync_devices" => true,
"device_role_filter" => "ap",
"site_filter" => "edge",
"tag_filter" => "prod"
})
test_pid = self()
Req.Test.stub(Client, fn conn ->
send(test_pid, {:device_qs, conn.query_string})
Req.Test.json(conn, %{"results" => []})
end)
assert {:ok, %{devices: 0}} = Sync.sync_organization(integration)
assert_received {:device_qs, qs}
assert qs =~ "role=ap"
assert qs =~ "site=edge"
assert qs =~ "tag=prod"
end
test "device sync HTTP 404 updates sync status to failed" do
{_org, integration} = setup_integration(%{"sync_devices" => true})
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(404)
|> Req.Test.json(%{})
end)
assert {:error, :not_found} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload!(integration)
assert reloaded.last_sync_status == "failed"
assert reloaded.last_sync_message =~ "API endpoint not found"
end
test "counts only successful upserts; logs (and skips) failed ones" do
{org, integration} = setup_integration(%{"sync_devices" => true})
# Good device: valid IP, valid name, non-null role
good =
netbox_device("good-router", %{"role" => %{"name" => "router"}})
# Bad device: missing IP triggers validate_required([:ip_address, ...])
bad =
netbox_device("bad-router", %{
"primary_ip4" => nil,
"primary_ip" => nil,
"role" => %{"name" => "router"}
})
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{"results" => [good, bad]})
end)
# Logger level is :error in test config, so the warning isn't captured.
# We only assert that the count reflects the successful upsert.
ExUnit.CaptureLog.capture_log(fn ->
assert {:ok, %{devices: 1}} = Sync.sync_organization(integration)
end)
[device] = Devices.list_organization_devices(org.id)
assert device.name == "good-router"
end
end
describe "sync_organization/1 — humanizes transport errors" do
test "Req transport error becomes a generic failed message" do
{_org, integration} = setup_integration(%{"sync_sites" => true})
Req.Test.stub(Client, fn conn ->
Req.Test.transport_error(conn, :econnrefused)
end)
assert {:error, _reason} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload!(integration)
assert reloaded.last_sync_status == "failed"
assert is_binary(reloaded.last_sync_message)
end
end
end

View file

@ -0,0 +1,106 @@
defmodule Towerops.Organizations.MembershipQueryTest do
use Towerops.DataCase, async: true
use ExUnitProperties
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Organizations
alias Towerops.Organizations.Membership
alias Towerops.Organizations.MembershipQuery
alias Towerops.Repo
setup do
owner = user_fixture()
org = organization_fixture(owner.id)
other = user_fixture()
{:ok, _} =
Organizations.create_membership(%{
organization_id: org.id,
user_id: other.id,
role: :technician
})
%{owner: owner, other: other, org: org}
end
describe "base/0" do
test "is the Membership schema" do
assert MembershipQuery.base() == Membership
end
end
describe "for_organization/2" do
test "scopes to a single organization", %{org: org} do
results =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> Repo.all()
assert Enum.all?(results, &(&1.organization_id == org.id))
assert length(results) >= 2
end
test "returns [] for unknown org" do
assert [] = Ecto.UUID.generate() |> MembershipQuery.for_organization() |> Repo.all()
end
end
describe "for_user/2" do
test "scopes to a single user", %{owner: owner, org: org} do
[result] =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> MembershipQuery.for_user(owner.id)
|> Repo.all()
assert result.user_id == owner.id
end
end
describe "with_role/2 and with_role_in/2" do
test "with_role narrows to one role", %{org: org} do
[result] =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> MembershipQuery.with_role(:owner)
|> Repo.all()
assert result.role == :owner
end
test "with_role_in matches any in list", %{org: org} do
results =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> MembershipQuery.with_role_in([:owner, :technician])
|> Repo.all()
roles = MapSet.new(results, & &1.role)
assert MapSet.member?(roles, :owner)
assert MapSet.member?(roles, :technician)
end
property "with_role_in([role]) returns same set as with_role(role)", %{org: org} do
check all(role <- StreamData.member_of([:owner, :technician]), max_runs: 5) do
a =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> MembershipQuery.with_role(role)
|> Repo.all()
|> MapSet.new(& &1.id)
b =
MembershipQuery.base()
|> MembershipQuery.for_organization(org.id)
|> MembershipQuery.with_role_in([role])
|> Repo.all()
|> MapSet.new(& &1.id)
assert a == b
end
end
end
end

View file

@ -0,0 +1,147 @@
defmodule Towerops.Snmp.ArpEntriesTest do
use Towerops.DataCase, async: true
import Towerops.DevicesFixtures
alias Towerops.Repo
alias Towerops.Snmp.ArpEntries
alias Towerops.Snmp.ArpEntry
setup do
device = device_fixture()
%{device: device, organization_id: device.organization_id}
end
describe "list_arp_entries/1" do
test "returns ARP entries ordered by IP", %{device: device} do
_ = upsert!(device.id, "192.168.1.10", "AA:AA:AA:AA:AA:01")
_ = upsert!(device.id, "192.168.1.5", "AA:AA:AA:AA:AA:02")
result = ArpEntries.list_arp_entries(device.id)
assert Enum.map(result, & &1.ip_address) == ["192.168.1.10", "192.168.1.5"]
end
test "returns [] for unknown device" do
assert ArpEntries.list_arp_entries(Ecto.UUID.generate()) == []
end
end
describe "get_arp_entry/1" do
test "returns the entry by id", %{device: device} do
e = upsert!(device.id, "192.168.1.5", "AA:AA:AA:AA:AA:01")
assert %ArpEntry{id: id} = ArpEntries.get_arp_entry(e.id)
assert id == e.id
end
test "returns nil for unknown id" do
assert is_nil(ArpEntries.get_arp_entry(Ecto.UUID.generate()))
end
end
describe "upsert_arp_entry/1" do
test "creates new entry", %{device: device} do
attrs = %{
device_id: device.id,
ip_address: "192.168.1.5",
mac_address: "AA:AA:AA:AA:AA:01",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %ArpEntry{ip_address: "192.168.1.5"}} = ArpEntries.upsert_arp_entry(attrs)
end
test "upserts on (device_id, ip_address, mac_address) conflict", %{device: device} do
attrs = %{
device_id: device.id,
ip_address: "192.168.1.5",
mac_address: "AA:AA:AA:AA:AA:01",
entry_type: "dynamic",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %{id: id}} = ArpEntries.upsert_arp_entry(attrs)
attrs2 = %{attrs | entry_type: "static"}
assert {:ok, %{id: ^id, entry_type: "static"}} = ArpEntries.upsert_arp_entry(attrs2)
assert Repo.aggregate(ArpEntry, :count) == 1
end
end
describe "delete_stale_arp_entries/2" do
test "deletes entries older than cutoff", %{device: device} do
_ = upsert!(device.id, "10.0.0.1", "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z])
_ = upsert!(device.id, "10.0.0.2", "AA:AA:AA:AA:AA:02", last_seen_at: ~U[2026-01-10 00:00:00Z])
assert {1, _} = ArpEntries.delete_stale_arp_entries(device.id, ~U[2026-01-05 00:00:00Z])
assert Repo.aggregate(ArpEntry, :count) == 1
end
end
describe "upsert_arp_entries/3" do
test "counts successes when no interface match", %{device: device} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{ip_address: "10.0.0.1", mac_address: "AA:AA:AA:AA:AA:01", if_index: 1, last_seen_at: now},
%{ip_address: "10.0.0.2", mac_address: "AA:AA:AA:AA:AA:02", if_index: 2, last_seen_at: now}
]
assert {2, 0} = ArpEntries.upsert_arp_entries(device.id, entries, [])
end
test "counts errors when changeset fails", %{device: device} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{ip_address: nil, mac_address: nil, if_index: 1, last_seen_at: now},
%{ip_address: "10.0.0.2", mac_address: "AA:AA:AA:AA:AA:02", if_index: 1, last_seen_at: now}
]
previous = Logger.level()
Logger.configure(level: :warning)
on_exit(fn -> Logger.configure(level: previous) end)
log =
ExUnit.CaptureLog.capture_log(fn ->
assert {1, 1} = ArpEntries.upsert_arp_entries(device.id, entries, [])
end)
assert log =~ "ARP upsert failures"
end
end
describe "delete_stale_and_upsert_arp_entries/4" do
test "wraps delete + upsert in a transaction", %{device: device} do
_ = upsert!(device.id, "10.0.0.1", "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z])
now = DateTime.truncate(DateTime.utc_now(), :second)
new_entries = [
%{ip_address: "10.0.0.99", mac_address: "AA:AA:AA:AA:AA:99", if_index: 1, last_seen_at: now}
]
assert {:ok, {1, 0}} =
ArpEntries.delete_stale_and_upsert_arp_entries(
device.id,
new_entries,
[],
~U[2026-01-05 00:00:00Z]
)
assert Repo.aggregate(ArpEntry, :count) == 1
end
end
defp upsert!(device_id, ip, mac, extra \\ []) do
last_seen = Keyword.get(extra, :last_seen_at, DateTime.truncate(DateTime.utc_now(), :second))
attrs =
Enum.into(extra, %{
device_id: device_id,
ip_address: ip,
mac_address: mac,
last_seen_at: last_seen
})
{:ok, e} = ArpEntries.upsert_arp_entry(attrs)
e
end
end

View file

@ -0,0 +1,90 @@
defmodule Towerops.Snmp.IpAddressesTest do
use Towerops.DataCase, async: true
import Towerops.SnmpFixtures
alias Towerops.Repo
alias Towerops.Snmp.Interface
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.IpAddresses
setup do
snmp_device = snmp_device_fixture()
iface = insert_interface!(snmp_device.id, 1)
%{snmp_device: snmp_device, interface: iface}
end
describe "list_ip_addresses/1" do
test "returns IPs across all interfaces of a device", %{snmp_device: snmp_device, interface: iface} do
_ = insert_ip!(iface.id, "192.168.1.1", "ipv4")
_ = insert_ip!(iface.id, "10.0.0.1", "ipv4")
iface2 = insert_interface!(snmp_device.id, 2)
_ = insert_ip!(iface2.id, "fe80::1", "ipv6")
result = IpAddresses.list_ip_addresses(snmp_device.id)
assert length(result) == 3
assert Enum.all?(result, &Ecto.assoc_loaded?(&1.snmp_interface))
end
test "scopes results to the requested device", %{snmp_device: snmp_device, interface: iface} do
_ = insert_ip!(iface.id, "10.0.0.1", "ipv4")
other_device = snmp_device_fixture()
other_iface = insert_interface!(other_device.id, 1)
_ = insert_ip!(other_iface.id, "10.0.0.2", "ipv4")
assert [%{ip_address: ip}] = IpAddresses.list_ip_addresses(snmp_device.id)
assert ip == "10.0.0.1"
end
test "returns [] for device with no IPs" do
assert IpAddresses.list_ip_addresses(Ecto.UUID.generate()) == []
end
test "orders by ip_type then ip_address", %{snmp_device: snmp_device, interface: iface} do
_ = insert_ip!(iface.id, "192.168.1.5", "ipv4")
_ = insert_ip!(iface.id, "192.168.1.1", "ipv4")
_ = insert_ip!(iface.id, "fe80::1", "ipv6")
result = IpAddresses.list_ip_addresses(snmp_device.id)
assert Enum.map(result, & &1.ip_address) == ["192.168.1.1", "192.168.1.5", "fe80::1"]
end
end
describe "list_interface_ip_addresses/1" do
test "returns IPs only for that interface", %{interface: iface} do
_ = insert_ip!(iface.id, "192.168.1.1", "ipv4")
_ = insert_ip!(iface.id, "192.168.1.2", "ipv4")
result = IpAddresses.list_interface_ip_addresses(iface.id)
assert length(result) == 2
end
test "returns [] for unknown interface" do
assert IpAddresses.list_interface_ip_addresses(Ecto.UUID.generate()) == []
end
end
defp insert_interface!(snmp_device_id, idx) do
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device_id,
if_index: idx,
if_name: "eth#{idx}",
if_descr: "Ethernet #{idx}"
})
|> Repo.insert!()
end
defp insert_ip!(interface_id, address, type) do
%IpAddress{}
|> IpAddress.changeset(%{
snmp_interface_id: interface_id,
ip_address: address,
ip_type: type,
prefix_length: if(type == "ipv4", do: 24, else: 64)
})
|> Repo.insert!()
end
end

View file

@ -0,0 +1,189 @@
defmodule Towerops.Snmp.MempoolsTest do
use Towerops.DataCase, async: true
import Towerops.SnmpFixtures
alias Towerops.Repo
alias Towerops.Snmp.Mempool
alias Towerops.Snmp.MempoolReading
alias Towerops.Snmp.Mempools
setup do
snmp_device = snmp_device_fixture()
%{snmp_device: snmp_device}
end
describe "list_mempools/1" do
test "returns mempools for a device, ordered by mempool_index", %{snmp_device: snmp_device} do
_ = insert_mempool!(snmp_device.id, "2")
_ = insert_mempool!(snmp_device.id, "1")
_ = insert_mempool!(snmp_device.id, "3")
result = Mempools.list_mempools(snmp_device.id)
assert Enum.map(result, & &1.mempool_index) == ["1", "2", "3"]
end
test "scopes results to the requested device", %{snmp_device: snmp_device} do
other = snmp_device_fixture()
keep = insert_mempool!(snmp_device.id, "1")
_drop = insert_mempool!(other.id, "1")
assert [%{id: id}] = Mempools.list_mempools(snmp_device.id)
assert id == keep.id
end
test "returns [] for a device with no mempools" do
assert Mempools.list_mempools(Ecto.UUID.generate()) == []
end
end
describe "update_mempool/2" do
test "applies attribute changes", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
assert {:ok, updated} = Mempools.update_mempool(mempool, %{usage_percent: 42.0, used_bytes: 100})
assert updated.usage_percent == 42.0
assert updated.used_bytes == 100
end
end
describe "get_mempool_readings/2" do
test "returns most recent first, capped by :limit", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
_r1 = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z])
r2 = insert_reading!(mempool.id, ~U[2026-01-02 00:00:00Z])
r3 = insert_reading!(mempool.id, ~U[2026-01-03 00:00:00Z])
result = Mempools.get_mempool_readings(mempool.id, limit: 2)
assert Enum.map(result, & &1.id) == [r3.id, r2.id]
end
test "filters readings by :since", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
_old = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z])
newish = insert_reading!(mempool.id, ~U[2026-01-05 00:00:00Z])
result = Mempools.get_mempool_readings(mempool.id, since: ~U[2026-01-04 00:00:00Z])
assert Enum.map(result, & &1.id) == [newish.id]
end
test "ignores readings from other mempools", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
other_mempool = insert_mempool!(snmp_device.id, "2")
r = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z])
_ = insert_reading!(other_mempool.id, ~U[2026-01-01 00:00:00Z])
assert [%{id: id}] = Mempools.get_mempool_readings(mempool.id)
assert id == r.id
end
end
describe "get_latest_mempool_reading/1" do
test "returns the latest reading for a single mempool", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
_ = insert_reading!(mempool.id, ~U[2026-01-01 00:00:00Z])
latest = insert_reading!(mempool.id, ~U[2026-01-09 00:00:00Z])
assert %{id: id} = Mempools.get_latest_mempool_reading(mempool.id)
assert id == latest.id
end
test "returns nil when no readings exist" do
assert Mempools.get_latest_mempool_reading(Ecto.UUID.generate()) == nil
end
end
describe "get_latest_mempool_readings_batch/1" do
test "returns %{} for an empty list" do
assert Mempools.get_latest_mempool_readings_batch([]) == %{}
end
test "returns latest reading per mempool keyed by mempool_id", %{snmp_device: snmp_device} do
m1 = insert_mempool!(snmp_device.id, "1")
m2 = insert_mempool!(snmp_device.id, "2")
m3 = insert_mempool!(snmp_device.id, "3")
_ = insert_reading!(m1.id, ~U[2026-01-01 00:00:00Z])
m1_latest = insert_reading!(m1.id, ~U[2026-01-05 00:00:00Z])
m2_latest = insert_reading!(m2.id, ~U[2026-01-02 00:00:00Z])
result = Mempools.get_latest_mempool_readings_batch([m1.id, m2.id, m3.id])
assert result[m1.id].id == m1_latest.id
assert result[m2.id].id == m2_latest.id
refute Map.has_key?(result, m3.id)
end
end
describe "create_mempool_reading/1" do
test "inserts a single reading", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
attrs = %{
mempool_id: mempool.id,
used_bytes: 100,
total_bytes: 1000,
free_bytes: 900,
usage_percent: 10.0,
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %MempoolReading{} = reading} = Mempools.create_mempool_reading(attrs)
assert reading.used_bytes == 100
end
test "returns error for invalid attrs" do
assert {:error, %Ecto.Changeset{}} = Mempools.create_mempool_reading(%{})
end
end
describe "create_mempool_readings_batch/1" do
test "returns {0, nil} for empty list" do
assert Mempools.create_mempool_readings_batch([]) == {0, nil}
end
test "inserts many readings in one shot", %{snmp_device: snmp_device} do
mempool = insert_mempool!(snmp_device.id, "1")
now = Towerops.Time.now()
entries =
for n <- 1..3 do
%{
mempool_id: mempool.id,
used_bytes: n * 100,
total_bytes: 1000,
free_bytes: 1000 - n * 100,
usage_percent: n * 10.0,
checked_at: now
}
end
assert {3, nil} = Mempools.create_mempool_readings_batch(entries)
assert Repo.aggregate(MempoolReading, :count, :id) == 3
end
end
defp insert_mempool!(snmp_device_id, index) do
%Mempool{}
|> Mempool.changeset(%{
snmp_device_id: snmp_device_id,
mempool_index: index,
mempool_type: "hr_memory",
description: "Memory pool #{index}"
})
|> Repo.insert!()
end
defp insert_reading!(mempool_id, checked_at) do
%MempoolReading{}
|> MempoolReading.changeset(%{
mempool_id: mempool_id,
used_bytes: 100,
total_bytes: 1000,
free_bytes: 900,
usage_percent: 10.0,
checked_at: checked_at
})
|> Repo.insert!()
end
end

View file

@ -0,0 +1,206 @@
defmodule Towerops.Snmp.NeighborsTest do
use Towerops.DataCase, async: true
import Towerops.DevicesFixtures
import Towerops.SnmpFixtures
alias Towerops.Repo
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Neighbors
setup do
device = device_fixture()
snmp_device = snmp_device_fixture(%{device: device})
interface = insert_interface!(snmp_device.id, 1, "AA:BB:CC:DD:EE:FF")
%{
device: device,
organization_id: device.organization_id,
snmp_device: snmp_device,
interface: interface
}
end
describe "list_neighbors/1" do
test "returns neighbors for a device, ordered by protocol then remote_system_name", %{
device: device,
interface: iface
} do
_ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha")
_ = upsert_neighbor!(device.id, iface.id, "cdp", "beta")
result = Neighbors.list_neighbors(device.id)
assert Enum.map(result, & &1.protocol) == ["cdp", "lldp"]
end
test "returns [] for unknown device" do
assert Neighbors.list_neighbors(Ecto.UUID.generate()) == []
end
end
describe "get_neighbor/1" do
test "fetches by id", %{device: device, interface: iface} do
n = upsert_neighbor!(device.id, iface.id, "lldp", "alpha")
assert %Neighbor{id: id} = Neighbors.get_neighbor(n.id)
assert id == n.id
end
test "returns nil for unknown id" do
assert is_nil(Neighbors.get_neighbor(Ecto.UUID.generate()))
end
end
describe "upsert_neighbor/1" do
test "creates new neighbor", %{device: device, interface: iface} do
attrs = %{
device_id: device.id,
interface_id: iface.id,
protocol: "lldp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "switch-01",
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, neighbor} = Neighbors.upsert_neighbor(attrs)
assert neighbor.remote_system_name == "switch-01"
end
test "second upsert with same conflict_target updates the row", %{device: device, interface: iface} do
attrs = %{
device_id: device.id,
interface_id: iface.id,
protocol: "lldp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "old-name",
last_discovered_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %{id: id}} = Neighbors.upsert_neighbor(attrs)
attrs2 = %{attrs | remote_system_name: "new-name"}
assert {:ok, %{id: ^id, remote_system_name: "new-name"}} = Neighbors.upsert_neighbor(attrs2)
assert Repo.aggregate(Neighbor, :count) == 1
end
end
describe "delete_stale_neighbors/2" do
test "deletes neighbors older than cutoff", %{device: device, interface: iface} do
_ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha", last_discovered_at: ~U[2026-01-01 00:00:00Z])
_ = upsert_neighbor!(device.id, iface.id, "cdp", "beta", last_discovered_at: ~U[2026-01-10 00:00:00Z])
assert {1, _} = Neighbors.delete_stale_neighbors(device.id, ~U[2026-01-05 00:00:00Z])
assert Repo.aggregate(Neighbor, :count) == 1
end
end
describe "delete_stale_and_upsert_neighbors/3" do
test "wraps delete + upsert in a transaction", %{device: device, interface: iface} do
_ = upsert_neighbor!(device.id, iface.id, "lldp", "alpha", last_discovered_at: ~U[2026-01-01 00:00:00Z])
now = DateTime.truncate(DateTime.utc_now(), :second)
new_neighbors = [
%{
device_id: device.id,
interface_id: iface.id,
protocol: "lldp",
remote_chassis_id: "AA:BB:CC:DD:EE:FF",
remote_system_name: "fresh",
last_discovered_at: now
}
]
assert {:ok, :ok} =
Neighbors.delete_stale_and_upsert_neighbors(
device.id,
new_neighbors,
~U[2026-01-05 00:00:00Z]
)
assert Repo.aggregate(Neighbor, :count) == 1
end
test "logs but tolerates a bad neighbor entry", %{device: device, interface: iface} do
now = DateTime.truncate(DateTime.utc_now(), :second)
bad_neighbor = %{
device_id: device.id,
interface_id: iface.id,
protocol: nil,
remote_chassis_id: nil,
last_discovered_at: now
}
previous = Logger.level()
Logger.configure(level: :error)
on_exit(fn -> Logger.configure(level: previous) end)
log =
ExUnit.CaptureLog.capture_log(fn ->
assert {:ok, :ok} =
Neighbors.delete_stale_and_upsert_neighbors(
device.id,
[bad_neighbor],
~U[2026-01-05 00:00:00Z]
)
end)
assert log =~ "Failed to upsert neighbor"
end
end
describe "list_neighbors_with_equipment/1 — match by name" do
test "matches a neighbor by remote_system_name to a device in the same org", %{
device: device,
interface: iface,
organization_id: org_id
} do
neighbor_target = device_fixture(%{organization_id: org_id, name: "switch-21"})
_ =
upsert_neighbor!(device.id, iface.id, "lldp", "switch-21", remote_chassis_id: "AA:BB:CC:DD:EE:00")
[enriched] = Neighbors.list_neighbors_with_equipment(device.id)
assert enriched.matched_device.id == neighbor_target.id
end
test "returns nil match when no device matches", %{
device: device,
interface: iface
} do
_ = upsert_neighbor!(device.id, iface.id, "lldp", "no-such-host")
[enriched] = Neighbors.list_neighbors_with_equipment(device.id)
assert is_nil(enriched.matched_device)
end
end
defp upsert_neighbor!(device_id, interface_id, protocol, system_name, extra \\ []) do
chassis = Keyword.get(extra, :remote_chassis_id, "AA:BB:CC:DD:EE:#{Enum.random(10..99)}")
last = Keyword.get(extra, :last_discovered_at, DateTime.truncate(DateTime.utc_now(), :second))
attrs = %{
device_id: device_id,
interface_id: interface_id,
protocol: protocol,
remote_chassis_id: chassis,
remote_system_name: system_name,
last_discovered_at: last
}
{:ok, n} = Neighbors.upsert_neighbor(attrs)
n
end
defp insert_interface!(snmp_device_id, idx, mac) do
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device_id,
if_index: idx,
if_name: "eth#{idx}",
if_descr: "Ethernet #{idx}",
if_phys_address: mac
})
|> Repo.insert!()
end
end

View file

@ -0,0 +1,114 @@
defmodule Towerops.Snmp.ProcessorsTest do
use Towerops.DataCase, async: true
import Towerops.SnmpFixtures
alias Towerops.Repo
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
alias Towerops.Snmp.Processors
setup do
snmp_device = snmp_device_fixture()
%{snmp_device: snmp_device}
end
describe "list_processors/1" do
test "returns processors for a device, ordered by processor_index", %{snmp_device: snmp_device} do
_ = insert_processor!(snmp_device.id, "2")
_ = insert_processor!(snmp_device.id, "1")
_ = insert_processor!(snmp_device.id, "3")
result = Processors.list_processors(snmp_device.id)
assert Enum.map(result, & &1.processor_index) == ["1", "2", "3"]
end
test "scopes to the requested device", %{snmp_device: snmp_device} do
other = snmp_device_fixture()
keep = insert_processor!(snmp_device.id, "1")
_drop = insert_processor!(other.id, "1")
assert [%{id: id}] = Processors.list_processors(snmp_device.id)
assert id == keep.id
end
test "returns [] for a device with no processors" do
assert Processors.list_processors(Ecto.UUID.generate()) == []
end
end
describe "get_processor/1" do
test "returns the processor by id", %{snmp_device: snmp_device} do
processor = insert_processor!(snmp_device.id, "1")
assert %Processor{id: id} = Processors.get_processor(processor.id)
assert id == processor.id
end
test "returns nil for unknown id" do
assert is_nil(Processors.get_processor(Ecto.UUID.generate()))
end
end
describe "update_processor/2" do
test "applies attribute changes", %{snmp_device: snmp_device} do
processor = insert_processor!(snmp_device.id, "1")
assert {:ok, updated} = Processors.update_processor(processor, %{load_percent: 42.0})
assert updated.load_percent == 42.0
end
end
describe "get_processor_readings/2" do
test "returns most recent first, capped by :limit", %{snmp_device: snmp_device} do
processor = insert_processor!(snmp_device.id, "1")
_r1 = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z])
r2 = insert_reading!(processor.id, ~U[2026-01-02 00:00:00Z])
r3 = insert_reading!(processor.id, ~U[2026-01-03 00:00:00Z])
result = Processors.get_processor_readings(processor.id, limit: 2)
assert Enum.map(result, & &1.id) == [r3.id, r2.id]
end
test "filters readings by :since", %{snmp_device: snmp_device} do
processor = insert_processor!(snmp_device.id, "1")
_old = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z])
newish = insert_reading!(processor.id, ~U[2026-01-05 00:00:00Z])
result = Processors.get_processor_readings(processor.id, since: ~U[2026-01-04 00:00:00Z])
assert Enum.map(result, & &1.id) == [newish.id]
end
test "ignores readings from other processors", %{snmp_device: snmp_device} do
processor = insert_processor!(snmp_device.id, "1")
other_processor = insert_processor!(snmp_device.id, "2")
r = insert_reading!(processor.id, ~U[2026-01-01 00:00:00Z])
_ = insert_reading!(other_processor.id, ~U[2026-01-01 00:00:00Z])
assert [%{id: id}] = Processors.get_processor_readings(processor.id)
assert id == r.id
end
end
defp insert_processor!(snmp_device_id, index) do
%Processor{}
|> Processor.changeset(%{
snmp_device_id: snmp_device_id,
processor_index: index,
processor_type: "hr_processor",
description: "CPU #{index}"
})
|> Repo.insert!()
end
defp insert_reading!(processor_id, checked_at) do
%ProcessorReading{}
|> ProcessorReading.changeset(%{
processor_id: processor_id,
load_percent: 50.0,
status: "ok",
checked_at: checked_at
})
|> Repo.insert!()
end
end

View file

@ -1,31 +1,136 @@
defmodule Towerops.Snmp.WirelessClientDiscoveryTest do
@moduledoc """
Unit tests for the main module. Pure helpers are already covered in the
Parser submodule test file. Here we test the dispatch and the `:ok, []`
fall-through for unknown vendor profiles.
Tests for vendor-specific dispatch in `WirelessClientDiscovery`.
The full per-vendor SNMP walks are exercised through a stub adapter
installed via `:adapter` option in `client_opts`, which the SNMP Client
honors as a test seam.
"""
use ExUnit.Case, async: true
alias Towerops.Snmp.WirelessClientDiscovery
defmodule StubAdapter do
@moduledoc false
def put_walks(map), do: Process.put(:wcd_walks, map)
# Adapter contract: walk(opts, oid) → {:ok, [{oid_str, value}, ...]}
def walk(_opts, oid) do
walks = Process.get(:wcd_walks, %{})
pairs = Map.get(walks, oid, [])
{:ok, pairs}
end
def get(_opts, _oid), do: {:ok, nil}
end
defp opts_with_stub, do: [adapter: StubAdapter]
describe "discover_wireless_clients/2 dispatch" do
test "unknown vendor yields empty list without SNMP calls" do
# `client_opts` would only be used if an SNMP walk were attempted.
# Passing nil here proves the unknown-vendor branch short-circuits first.
assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "unknown")
assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, nil)
assert {:ok, []} = WirelessClientDiscovery.discover_wireless_clients(nil, "")
end
end
describe "discover_wireless_clients/2 with stub adapter" do
setup do
StubAdapter.put_walks(%{})
:ok
end
test "ePMP profile returns clients for known walks" do
base = "1.3.6.1.4.1.17713.21.1.2.30.1"
StubAdapter.put_walks(%{
"#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}],
"#{base}.10" => [{"#{base}.10.1.1", "192.168.1.10"}],
"#{base}.5" => [{"#{base}.5.1.1", -55}]
})
assert {:ok, clients} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "epmp")
assert is_list(clients)
end
test "ubnt airOS profile returns clients for known walks" do
base = "1.3.6.1.4.1.41112.1.4.7.1"
StubAdapter.put_walks(%{
"#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}],
"#{base}.2" => [{"#{base}.2.1.1", "client-host"}]
})
assert {:ok, clients} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "airos")
assert is_list(clients)
end
test "mikrotik routeros profile returns clients for known walks" do
base = "1.3.6.1.4.1.14988.1.1.1.2.1"
StubAdapter.put_walks(%{
"#{base}.1" => [{"#{base}.1.1.1", <<170, 187, 204, 221, 238, 255>>}]
})
assert {:ok, clients} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "routeros")
assert is_list(clients)
end
test "ePMP with no walk data returns empty list" do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "epmp")
end
test "ubnt with no walk data returns empty list" do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "airos")
end
test "routeros with no walk data returns empty list" do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), "routeros")
end
test "ePMP aliases (cambium, pmp) all dispatch to ePMP code path" do
for profile <- ["epmp", "cambium", "pmp"] do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile)
end
end
test "ubnt aliases all dispatch to ubnt code path" do
for profile <- ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile)
end
end
test "mikrotik aliases dispatch to mikrotik code path" do
for profile <- ["routeros", "mikrotik"] do
assert {:ok, []} =
WirelessClientDiscovery.discover_wireless_clients(opts_with_stub(), profile)
end
end
end
describe "detect_vendor_profile/1 (delegated)" do
test "returns nil for empty device" do
assert nil == WirelessClientDiscovery.detect_vendor_profile(%{})
assert nil == WirelessClientDiscovery.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil})
assert nil ==
WirelessClientDiscovery.detect_vendor_profile(%{sys_object_id: nil, manufacturer: nil})
end
test "detects MikroTik" do
assert "routeros" == WirelessClientDiscovery.detect_vendor_profile(%{manufacturer: "MikroTik", sys_object_id: nil})
assert "routeros" ==
WirelessClientDiscovery.detect_vendor_profile(%{manufacturer: "MikroTik", sys_object_id: nil})
end
end
end

View file

@ -0,0 +1,108 @@
defmodule Towerops.Snmp.WirelessClientReadingTest do
use ExUnit.Case, async: true
alias Towerops.Snmp.WirelessClientReading
describe "changeset/2" do
test "valid with required fields" do
attrs = %{
device_id: Ecto.UUID.generate(),
wireless_client_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF",
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
assert changeset.valid?
end
test "valid with all metric fields" do
attrs = %{
device_id: Ecto.UUID.generate(),
wireless_client_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF",
ip_address: "192.168.1.10",
signal_strength: -55,
snr: 25,
distance: 1500,
tx_rate: 1200,
rx_rate: 800,
uptime_seconds: 3600,
metadata: %{"vendor" => "ubiquiti_airos"},
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
assert changeset.valid?
assert changeset.changes.metadata == %{"vendor" => "ubiquiti_airos"}
assert changeset.changes.signal_strength == -55
end
test "invalid without device_id" do
attrs = %{
wireless_client_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF",
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
refute changeset.valid?
assert {:device_id, _} = List.keyfind(changeset.errors, :device_id, 0)
end
test "invalid without wireless_client_id" do
attrs = %{
device_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF",
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
refute changeset.valid?
assert {:wireless_client_id, _} = List.keyfind(changeset.errors, :wireless_client_id, 0)
end
test "invalid without organization_id" do
attrs = %{
device_id: Ecto.UUID.generate(),
wireless_client_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF",
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
refute changeset.valid?
assert {:organization_id, _} = List.keyfind(changeset.errors, :organization_id, 0)
end
test "invalid without mac_address" do
attrs = %{
device_id: Ecto.UUID.generate(),
wireless_client_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
refute changeset.valid?
assert {:mac_address, _} = List.keyfind(changeset.errors, :mac_address, 0)
end
test "invalid without checked_at" do
attrs = %{
device_id: Ecto.UUID.generate(),
wireless_client_id: Ecto.UUID.generate(),
organization_id: Ecto.UUID.generate(),
mac_address: "AA:BB:CC:DD:EE:FF"
}
changeset = WirelessClientReading.changeset(%WirelessClientReading{}, attrs)
refute changeset.valid?
assert {:checked_at, _} = List.keyfind(changeset.errors, :checked_at, 0)
end
end
end

View file

@ -0,0 +1,210 @@
defmodule Towerops.Snmp.WirelessClientsTest do
use Towerops.DataCase, async: true
import Towerops.DevicesFixtures
alias Towerops.Repo
alias Towerops.Snmp.WirelessClient
alias Towerops.Snmp.WirelessClients
setup do
device = device_fixture()
%{device: device, organization_id: device.organization_id}
end
describe "list_wireless_clients/1" do
test "returns clients ordered by mac_address", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "BB:BB:BB:BB:BB:BB")
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:AA")
result = WirelessClients.list_wireless_clients(device.id)
assert Enum.map(result, & &1.mac_address) == ["AA:AA:AA:AA:AA:AA", "BB:BB:BB:BB:BB:BB"]
end
test "returns [] for unknown device" do
assert WirelessClients.list_wireless_clients(Ecto.UUID.generate()) == []
end
end
describe "get_wireless_clients_by_mac/2" do
test "returns matching clients in org", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:AA")
_ = upsert!(device.id, org_id, "BB:BB:BB:BB:BB:BB")
result =
WirelessClients.get_wireless_clients_by_mac(org_id, [
"AA:AA:AA:AA:AA:AA",
"ZZ:ZZ:ZZ:ZZ:ZZ:ZZ"
])
assert Enum.map(result, & &1.mac_address) == ["AA:AA:AA:AA:AA:AA"]
end
end
describe "upsert_wireless_client/1" do
test "creates a new entry", %{device: device, organization_id: org_id} do
assert {:ok, client} =
WirelessClients.upsert_wireless_client(%{
device_id: device.id,
organization_id: org_id,
mac_address: "AA:BB:CC:DD:EE:FF",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
})
assert client.mac_address == "AA:BB:CC:DD:EE:FF"
end
test "upserts on (device_id, mac_address) conflict", %{device: device, organization_id: org_id} do
attrs1 = %{
device_id: device.id,
organization_id: org_id,
mac_address: "AA:BB:CC:DD:EE:FF",
signal_strength: -50,
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
}
assert {:ok, %{id: id}} = WirelessClients.upsert_wireless_client(attrs1)
attrs2 = Map.put(attrs1, :signal_strength, -80)
assert {:ok, %{id: ^id, signal_strength: -80}} = WirelessClients.upsert_wireless_client(attrs2)
assert Repo.aggregate(WirelessClient, :count) == 1
end
end
describe "upsert_wireless_clients/3" do
test "counts successes and failures", %{device: device, organization_id: org_id} do
good = %{mac_address: "AA:AA:AA:AA:AA:01", last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)}
bad = %{mac_address: nil, last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)}
assert {1, 1} = WirelessClients.upsert_wireless_clients(device.id, org_id, [good, bad])
end
test "empty list returns {0, 0}", %{device: device, organization_id: org_id} do
assert {0, 0} = WirelessClients.upsert_wireless_clients(device.id, org_id, [])
end
end
describe "delete_stale_wireless_clients/2" do
test "deletes entries with last_seen_at < cutoff", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z])
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", last_seen_at: ~U[2026-01-10 00:00:00Z])
cutoff = ~U[2026-01-05 00:00:00Z]
assert {1, _} = WirelessClients.delete_stale_wireless_clients(device.id, cutoff)
assert Repo.aggregate(WirelessClient, :count) == 1
end
end
describe "delete_stale_and_upsert_wireless_clients/4" do
test "deletes stale + upserts new in one transaction", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", last_seen_at: ~U[2026-01-01 00:00:00Z])
now = DateTime.truncate(DateTime.utc_now(), :second)
new_clients = [%{mac_address: "BB:BB:BB:BB:BB:BB", last_seen_at: now}]
cutoff = ~U[2026-01-05 00:00:00Z]
assert {:ok, {1, 0}} =
WirelessClients.delete_stale_and_upsert_wireless_clients(
device.id,
org_id,
new_clients,
cutoff
)
assert Repo.aggregate(WirelessClient, :count) == 1
end
end
describe "create_wireless_client_readings_batch/1" do
test "empty list returns {0, nil}" do
assert {0, nil} = WirelessClients.create_wireless_client_readings_batch([])
end
@tag :skip
test "inserts readings with generated UUIDs and timestamps", %{device: device, organization_id: org_id} do
# Skipped: WirelessClientReading.device_id references snmp_devices, not devices,
# which makes a minimal fixture awkward. The empty-list path covers the function head we care about.
_ = {device, org_id}
:ok
end
end
describe "list_weak_signal_clients/2" do
test "returns clients with signal below threshold", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", signal_strength: -50)
weak = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", signal_strength: -90)
result = WirelessClients.list_weak_signal_clients(org_id, -75)
assert Enum.map(result, & &1.id) == [weak.id]
end
end
describe "list_low_snr_clients/2" do
test "returns clients with snr below threshold", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01", snr: 30)
poor = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02", snr: 5)
result = WirelessClients.list_low_snr_clients(org_id, 15)
assert Enum.map(result, & &1.id) == [poor.id]
end
end
describe "get_wireless_client_count_by_device/1" do
test "returns map of device → count", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01")
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:02")
assert %{} = result = WirelessClients.get_wireless_client_count_by_device(org_id)
assert result[device.id] == 2
end
end
describe "list_overloaded_aps/2" do
test "returns devices with > threshold clients", %{device: device, organization_id: org_id} do
Enum.each(1..3, fn n ->
upsert!(device.id, org_id, "AA:AA:AA:AA:AA:0#{n}")
end)
assert [{ap, 3}] = WirelessClients.list_overloaded_aps(org_id, 2)
assert ap.id == device.id
end
test "returns [] when no AP exceeds threshold", %{device: device, organization_id: org_id} do
_ = upsert!(device.id, org_id, "AA:AA:AA:AA:AA:01")
assert [] = WirelessClients.list_overloaded_aps(org_id, 100)
end
end
describe "get_wireless_client_last_seen/2" do
test "returns most recent last_seen_at for the MAC, case-insensitive", %{device: device, organization_id: org_id} do
newest = ~U[2026-01-10 12:00:00Z]
_ = upsert!(device.id, org_id, "AA:BB:CC:DD:EE:FF", last_seen_at: ~U[2026-01-01 12:00:00Z])
device2 = device_fixture(%{organization_id: org_id})
_ = upsert!(device2.id, org_id, "AA:BB:CC:DD:EE:FF", last_seen_at: newest)
assert WirelessClients.get_wireless_client_last_seen(org_id, "aa:bb:cc:dd:ee:ff") == newest
end
test "returns nil for unknown MAC", %{organization_id: org_id} do
assert is_nil(WirelessClients.get_wireless_client_last_seen(org_id, "00:00:00:00:00:00"))
end
end
defp upsert!(device_id, org_id, mac, extra \\ []) do
last_seen = Keyword.get(extra, :last_seen_at, DateTime.truncate(DateTime.utc_now(), :second))
attrs =
Enum.into(extra, %{
device_id: device_id,
organization_id: org_id,
mac_address: mac,
last_seen_at: last_seen
})
{:ok, client} = WirelessClients.upsert_wireless_client(attrs)
client
end
end

View file

@ -0,0 +1,118 @@
defmodule Towerops.Sonar.SyncIntegrationTest do
@moduledoc """
Drives `Towerops.Sonar.Sync.sync_organization/1` against `Req.Test`-stubbed
GraphQL responses. Pure helpers (humanize_sync_error / sum_service_prices /
add_price / calculate_total_mrr) are covered in `sync_test.exs`.
"""
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations
alias Towerops.Sonar.Client
alias Towerops.Sonar.Sync
setup do
user = user_fixture()
organization = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(organization.id, %{
provider: "sonar",
enabled: true,
credentials: %{
"instance_url" => "https://sonar.example.com",
"api_token" => "test-token-xyz"
}
})
%{integration: integration, organization: organization}
end
defp empty_page_result(root_key) do
%{
"data" => %{
root_key => %{
"entities" => [],
"page_info" => %{"total_pages" => 1, "page" => 1}
}
}
}
end
defp graphql_handler(stub_responses) do
fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
query = Jason.decode!(body)["query"]
response =
cond do
String.contains?(query, "accounts") -> stub_responses[:accounts]
String.contains?(query, "network_sites") -> stub_responses[:network_sites]
String.contains?(query, "inventory_items") -> stub_responses[:inventory_items]
true -> %{"data" => %{}}
end
Req.Test.json(conn, response)
end
end
describe "sync_organization/1 — happy path" do
test "returns counts and updates the integration to success", %{integration: integration} do
Req.Test.stub(
Client,
graphql_handler(%{
accounts: empty_page_result("accounts"),
network_sites: empty_page_result("network_sites"),
inventory_items: empty_page_result("inventory_items")
})
)
assert {:ok, summary} = Sync.sync_organization(integration)
assert summary == %{accounts: 0, network_sites: 0, inventory_items: 0}
reloaded = Towerops.Repo.reload(integration)
assert reloaded.last_sync_status == "success"
end
end
describe "sync_organization/1 — error paths" do
test "401 sets status=failed and returns {:error, :unauthorized}", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{})
end)
assert {:error, :unauthorized} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload(integration)
assert reloaded.last_sync_status == "failed"
assert reloaded.last_sync_message =~ "Authentication failed"
end
test "503 with retry-after sets status=failed", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_resp_header("retry-after", "60")
|> Plug.Conn.put_status(429)
|> Req.Test.json(%{})
end)
assert {:error, _} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload(integration)
assert reloaded.last_sync_status == "failed"
end
test "GraphQL errors propagate and are humanized", %{integration: integration} do
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{
"errors" => [%{"message" => "no permission"}, %{"message" => "bad query"}]
})
end)
assert {:error, _} = Sync.sync_organization(integration)
reloaded = Towerops.Repo.reload(integration)
assert reloaded.last_sync_status == "failed"
assert reloaded.last_sync_message =~ "GraphQL errors"
end
end
end

View file

@ -0,0 +1,81 @@
defmodule Towerops.StatusPages.StatusIncidentTest do
use ExUnit.Case, async: true
alias Towerops.StatusPages.StatusIncident
describe "changeset/2" do
test "valid with required fields" do
attrs = %{
title: "Outage in core",
status: "investigating",
started_at: DateTime.truncate(DateTime.utc_now(), :second),
status_page_config_id: Ecto.UUID.generate()
}
assert StatusIncident.changeset(%StatusIncident{}, attrs).valid?
end
test "rejects missing required fields" do
attrs = %{}
changeset = StatusIncident.changeset(%StatusIncident{}, attrs)
refute changeset.valid?
keys = Keyword.keys(changeset.errors)
assert :title in keys
assert :started_at in keys
assert :status_page_config_id in keys
end
test "rejects invalid severity" do
attrs = %{
title: "x",
status: "investigating",
severity: "catastrophic",
started_at: DateTime.truncate(DateTime.utc_now(), :second),
status_page_config_id: Ecto.UUID.generate()
}
changeset = StatusIncident.changeset(%StatusIncident{}, attrs)
refute changeset.valid?
assert {:severity, _} = List.keyfind(changeset.errors, :severity, 0)
end
test "rejects invalid status" do
attrs = %{
title: "x",
status: "nuked",
started_at: DateTime.truncate(DateTime.utc_now(), :second),
status_page_config_id: Ecto.UUID.generate()
}
changeset = StatusIncident.changeset(%StatusIncident{}, attrs)
refute changeset.valid?
assert {:status, _} = List.keyfind(changeset.errors, :status, 0)
end
test "accepts each valid severity and status" do
base_attrs = %{
title: "x",
started_at: DateTime.truncate(DateTime.utc_now(), :second),
status_page_config_id: Ecto.UUID.generate()
}
for severity <- StatusIncident.valid_severities() do
attrs = base_attrs |> Map.put(:severity, severity) |> Map.put(:status, "investigating")
assert StatusIncident.changeset(%StatusIncident{}, attrs).valid?, "expected severity #{severity} to be valid"
end
for status <- StatusIncident.valid_statuses() do
attrs = Map.put(base_attrs, :status, status)
assert StatusIncident.changeset(%StatusIncident{}, attrs).valid?, "expected status #{status} to be valid"
end
end
end
describe "valid_severities/0 and valid_statuses/0" do
test "return canonical lists" do
assert StatusIncident.valid_severities() == ~w(minor major critical)
assert StatusIncident.valid_statuses() == ~w(investigating identified monitoring resolved)
end
end
end

View file

@ -7,6 +7,8 @@ defmodule Towerops.Workers.AlertDigestWorkerTest do
alias Towerops.Alerts.NotificationDigest
alias Towerops.Workers.AlertDigestWorker
require Logger
defp insert_digest!(opts) do
user_id = Keyword.fetch!(opts, :user_id)
digest_sent = Keyword.get(opts, :digest_sent, false)
@ -58,6 +60,45 @@ defmodule Towerops.Workers.AlertDigestWorkerTest do
user = user_fixture()
assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id})
end
test "no-op when digest exists but suppressed alert IDs are stale (alerts deleted)" do
user = user_fixture()
stale_id = Ecto.UUID.generate()
_ = insert_digest!(user_id: user.id, alert_ids: [stale_id])
# alert_ids reference a UUID that does not exist; list_alerts_by_ids returns []
assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id})
end
test "delivers digest when real alerts exist for the user" do
import Towerops.DevicesFixtures
user = user_fixture()
device = device_fixture()
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "down"
})
digest = insert_digest!(user_id: user.id, alert_ids: [alert.id])
previous = Logger.level()
Logger.configure(level: :info)
on_exit(fn -> Logger.configure(level: previous) end)
log =
ExUnit.CaptureLog.capture_log(fn ->
assert :ok = perform_job(AlertDigestWorker, %{"user_id" => user.id})
end)
assert log =~ "1 suppressed alerts"
reloaded = Towerops.Repo.get!(NotificationDigest, digest.id)
assert reloaded.digest_sent
end
end
describe "enqueue/1" do

View file

@ -0,0 +1,117 @@
defmodule Towerops.Workers.AlertNotificationWorkerRoutingTest do
@moduledoc """
Drives `AlertNotificationWorker.perform/1` through routing variations
("builtin", "pagerduty", "both") and lifecycle actions
(trigger, acknowledge, resolve) using real DB fixtures.
PagerDuty calls fall through to {:error, :not_configured} since no
integration is provisioned. These tests verify the worker stays :ok in
every branch and never crashes.
"""
use Towerops.DataCase, async: false
import ExUnit.CaptureLog
alias Towerops.Alerts
alias Towerops.Alerts.Alert
alias Towerops.Organizations
alias Towerops.Repo
alias Towerops.Workers.AlertNotificationWorker
setup do
user = Towerops.AccountsFixtures.user_fixture()
{:ok, org} = Organizations.create_organization(%{name: "Notif Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "S-#{System.unique_integer([:positive])}",
organization_id: org.id
})
device =
Towerops.DevicesFixtures.device_fixture(%{
organization_id: org.id,
site_id: site.id,
name: "D-#{System.unique_integer([:positive])}"
})
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "device unreachable"
})
%{org: org, device: device, alert: alert, user: user}
end
defp set_routing!(org, routing) do
{:ok, _} = Organizations.update_organization(org, %{alert_routing: routing})
:ok
end
defp trigger_job(alert), do: %Oban.Job{args: %{"action" => "trigger", "alert_id" => alert.id}}
defp ack_job(alert), do: %Oban.Job{args: %{"action" => "acknowledge", "alert_id" => alert.id}}
defp resolve_job(alert), do: %Oban.Job{args: %{"action" => "resolve", "alert_id" => alert.id}}
describe "trigger" do
test "default builtin routing returns :ok", %{alert: alert} do
log = capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end)
assert is_binary(log)
end
test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do
:ok = set_routing!(org, "pagerduty")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end)
end
test "both routing exercises both branches", %{org: org, alert: alert} do
:ok = set_routing!(org, "both")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(trigger_job(alert)) end)
end
test "storm-suppressed alert short-circuits to :ok", %{alert: alert} do
suppressed =
alert
|> Alert.changeset(%{storm_suppressed: true})
|> Repo.update!()
capture_log(fn ->
assert :ok = AlertNotificationWorker.perform(trigger_job(suppressed))
end)
end
end
describe "acknowledge" do
test "builtin routing succeeds with no incident open", %{alert: alert} do
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end)
end
test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do
:ok = set_routing!(org, "pagerduty")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end)
end
test "both routing returns :ok", %{org: org, alert: alert} do
:ok = set_routing!(org, "both")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(ack_job(alert)) end)
end
end
describe "resolve" do
test "builtin routing returns :ok with no incident", %{alert: alert} do
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end)
end
test "pagerduty routing without integration returns :ok", %{org: org, alert: alert} do
:ok = set_routing!(org, "pagerduty")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end)
end
test "both routing returns :ok", %{org: org, alert: alert} do
:ok = set_routing!(org, "both")
capture_log(fn -> assert :ok = AlertNotificationWorker.perform(resolve_job(alert)) end)
end
end
end

View file

@ -0,0 +1,117 @@
defmodule Towerops.Workers.CnMaestroSyncWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import ExUnit.CaptureLog
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.CnMaestro.Client
alias Towerops.Integrations
alias Towerops.Workers.CnMaestroSyncWorker
describe "perform/1 dispatcher (empty args)" do
test "returns :ok and enqueues nothing when no integrations exist" do
assert :ok = perform_job(CnMaestroSyncWorker, %{})
assert [] = all_enqueued(worker: CnMaestroSyncWorker)
end
test "skips disabled cn_maestro integrations" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, _disabled} =
Integrations.create_integration(org.id, %{
provider: "cn_maestro",
enabled: false,
credentials: %{
"url" => "https://cn.example.com",
"client_id" => "id",
"client_secret" => "secret"
}
})
assert :ok = perform_job(CnMaestroSyncWorker, %{})
# Only enabled integrations are enqueued; disabled is skipped.
assert [] = all_enqueued(worker: CnMaestroSyncWorker)
end
test "enqueues a per-integration job for each enabled cn_maestro integration" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "cn_maestro",
enabled: true,
credentials: %{
"url" => "https://cn.example.com",
"client_id" => "id",
"client_secret" => "secret"
}
})
assert :ok = perform_job(CnMaestroSyncWorker, %{})
jobs = all_enqueued(worker: CnMaestroSyncWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args == %{"integration_id" => integration.id}
end
test "ignores integrations from other providers" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, _other} =
Integrations.create_integration(org.id, %{
provider: "preseem",
enabled: true,
credentials: %{"api_key" => "k"}
})
assert :ok = perform_job(CnMaestroSyncWorker, %{})
assert [] = all_enqueued(worker: CnMaestroSyncWorker)
end
end
describe "perform/1 (with integration_id)" do
test "returns :ok when integration not found" do
missing_id = Ecto.UUID.generate()
capture_log(fn ->
assert :ok = perform_job(CnMaestroSyncWorker, %{"integration_id" => missing_id})
end)
end
test "swallows sync errors and returns :ok (so Oban doesn't retry transient HTTP errors forever)" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "cn_maestro",
enabled: true,
credentials: %{
"url" => "https://cn.example.com",
"client_id" => "id",
"client_secret" => "secret"
}
})
# Stub HTTP boundary: token endpoint returns 401
Req.Test.stub(Client, fn conn ->
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
end)
capture_log(fn ->
assert :ok = perform_job(CnMaestroSyncWorker, %{"integration_id" => integration.id})
end)
# Worker swallows sync errors and reports :ok so transient failures don't
# exhaust Oban retries; the integration's status should now be "failed".
{:ok, refreshed} = Integrations.get_integration_by_id(integration.id)
assert refreshed.last_sync_status == "failed"
end
end
end

View file

@ -0,0 +1,31 @@
defmodule Towerops.Workers.JobCleanupTaskTest do
use Towerops.DataCase, async: false
alias Towerops.Workers.JobCleanupTask
setup do
previous_env = Application.get_env(:towerops, :env)
on_exit(fn ->
if previous_env do
Application.put_env(:towerops, :env, previous_env)
else
Application.delete_env(:towerops, :env)
end
end)
:ok
end
describe "run/0" do
test "skips work in non-prod environment" do
Application.put_env(:towerops, :env, :test)
assert :ok == JobCleanupTask.run() || JobCleanupTask.run() == nil
end
test "skips work in :dev environment" do
Application.put_env(:towerops, :env, :dev)
assert JobCleanupTask.run() == :ok || JobCleanupTask.run() == nil
end
end
end

View file

@ -2,11 +2,63 @@ defmodule Towerops.Workers.MikrotikBackupWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import ExUnit.CaptureLog
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
alias Towerops.Workers.MikrotikBackupWorker
describe "perform/1" do
test "returns :ok when no eligible devices exist" do
assert :ok = perform_job(MikrotikBackupWorker, %{})
end
test "returns :ok when devices exist but none have MikroTik creds" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Org-A"}, user.id)
{:ok, site} = Towerops.Sites.create_site(%{name: "Site-A", organization_id: org.id})
# Plain non-MikroTik device — must be excluded
{:ok, _device} =
Devices.create_device(%{
name: "Plain Device",
ip_address: "10.0.0.10",
site_id: site.id,
organization_id: org.id,
mikrotik_enabled: false
})
assert :ok = perform_job(MikrotikBackupWorker, %{})
end
test "skips device when it has MikroTik creds but no agent assigned" do
user = user_fixture()
{:ok, org} = Towerops.Organizations.create_organization(%{name: "Org-B"}, user.id)
{:ok, site} = Towerops.Sites.create_site(%{name: "Site-B", organization_id: org.id})
{:ok, device} =
Devices.create_device(%{
name: "MikroTik No Agent",
ip_address: "10.0.0.20",
site_id: site.id,
organization_id: org.id,
mikrotik_enabled: true,
mikrotik_username: "admin",
mikrotik_password: "secret",
mikrotik_port: 8729,
mikrotik_use_ssl: true
})
# No agent assignment — backup_device should return {:skipped, :no_agent}.
# Use capture_log to suppress noise; the worker still returns :ok
# because the device is "skipped", not "errored".
capture_log(fn ->
assert :ok = perform_job(MikrotikBackupWorker, %{})
end)
# No backup request should have been created since we skipped
assert BackupRequests.get_request_by_job_id("backup:#{device.id}:0") == nil
end
end
end

View file

@ -73,5 +73,47 @@ defmodule Towerops.Workers.ReportWorkerTest do
test "logs warning for missing report and returns :ok" do
assert :ok = perform_job(ReportWorker, %{"report_id" => Ecto.UUID.generate()})
end
test "generates and delivers an uptime_summary report (success path)", %{org: org} do
report = create_report(org, %{name: "uptime", report_type: "uptime_summary"})
assert :ok = perform_job(ReportWorker, %{"report_id" => report.id})
# mark_run("success") should have updated last_run_at
{:ok, refreshed} = Reports.get_report(report.id)
assert refreshed.last_run_at
assert refreshed.last_run_status == "success"
end
test "generates an alert_history report", %{org: org} do
report = create_report(org, %{name: "alerts", report_type: "alert_history"})
assert :ok = perform_job(ReportWorker, %{"report_id" => report.id})
{:ok, refreshed} = Reports.get_report(report.id)
assert refreshed.last_run_status == "success"
end
test "rescue clause records 'failed' status when report_type is unknown", %{org: org} do
# Create a report with valid type, then force-update to invalid type at the
# DB level to bypass changeset validation. This drives the `case` in
# generate_csv/1 into FunctionClauseError, which the worker rescues.
report = create_report(org, %{name: "broken"})
Towerops.Repo.update_all(
from(r in Towerops.Reports.Report, where: r.id == ^report.id),
set: [report_type: "no_such_type"]
)
log =
ExUnit.CaptureLog.capture_log(fn ->
assert :ok = perform_job(ReportWorker, %{"report_id" => report.id})
end)
assert log =~ "generation failed"
{:ok, refreshed} = Reports.get_report(report.id)
assert refreshed.last_run_status == "failed"
end
end
end

View file

@ -0,0 +1,150 @@
defmodule Towerops.Workers.UispSyncWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import ExUnit.CaptureLog
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations
alias Towerops.Uisp.Client
alias Towerops.Workers.UispSyncWorker
describe "perform/1 dispatcher (empty args)" do
test "returns :ok with no integrations" do
assert :ok = perform_job(UispSyncWorker, %{})
assert [] = all_enqueued(worker: UispSyncWorker)
end
test "skips disabled uisp integrations" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, _disabled} =
Integrations.create_integration(org.id, %{
provider: "uisp",
enabled: false,
credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"}
})
assert :ok = perform_job(UispSyncWorker, %{})
assert [] = all_enqueued(worker: UispSyncWorker)
end
test "enqueues per-integration jobs for enabled UISP integrations that are due" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "uisp",
enabled: true,
credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"}
})
assert :ok = perform_job(UispSyncWorker, %{})
jobs = all_enqueued(worker: UispSyncWorker)
assert length(jobs) == 1
[job] = jobs
assert job.args == %{"integration_id" => integration.id}
end
test "does not enqueue an integration that was just synced" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "uisp",
enabled: true,
sync_interval_minutes: 60,
credentials: %{"url" => "https://uisp.example.com", "api_key" => "k"}
})
# Mark it freshly synced
{:ok, _} =
Integrations.update_integration(integration, %{
last_synced_at: DateTime.utc_now(),
last_sync_status: "success"
})
assert :ok = perform_job(UispSyncWorker, %{})
assert [] = all_enqueued(worker: UispSyncWorker)
end
end
describe "perform/1 (with integration_id)" do
test "returns {:error, :not_found} for missing integration" do
missing_id = Ecto.UUID.generate()
capture_log(fn ->
assert {:error, :not_found} =
perform_job(UispSyncWorker, %{"integration_id" => missing_id})
end)
end
test "swallows API errors and returns :ok (so Oban doesn't retry forever)" do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "uisp",
enabled: true,
credentials: %{
"url" => "https://uisp.example.com/nms/api/v2.1",
"api_key" => "bad-key"
}
})
Req.Test.stub(Client, fn conn ->
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
end)
capture_log(fn ->
assert :ok = perform_job(UispSyncWorker, %{"integration_id" => integration.id})
end)
# Worker swallows sync errors and reports :ok; verify the integration
# status was updated to "failed" via the sync log path.
{:ok, refreshed} = Integrations.get_integration_by_id(integration.id)
assert refreshed.last_sync_status == "failed"
end
end
describe "should_sync?/1" do
test "true when last_synced_at is nil" do
assert UispSyncWorker.should_sync?(%{
last_synced_at: nil,
sync_interval_minutes: 10
})
end
test "false when last_synced_at is recent" do
refute UispSyncWorker.should_sync?(%{
last_synced_at: DateTime.utc_now(),
sync_interval_minutes: 10
})
end
test "true once enough time elapsed" do
long_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
assert UispSyncWorker.should_sync?(%{
last_synced_at: long_ago,
sync_interval_minutes: 10
})
end
test "defaults sync_interval_minutes to 10 when nil" do
# 11 minutes ago should be due with the default
eleven_min_ago = DateTime.add(DateTime.utc_now(), -11 * 60, :second)
assert UispSyncWorker.should_sync?(%{
last_synced_at: eleven_min_ago,
sync_interval_minutes: nil
})
end
end
end

View file

@ -0,0 +1,41 @@
defmodule Towerops.Workers.WeatherSyncWorkerTest do
use Towerops.DataCase, async: false
alias Towerops.Workers.WeatherSyncWorker
setup do
previous = Application.get_env(:towerops, :openweathermap_api_key)
on_exit(fn ->
if previous do
Application.put_env(:towerops, :openweathermap_api_key, previous)
else
Application.delete_env(:towerops, :openweathermap_api_key)
end
end)
:ok
end
describe "perform/1" do
test "returns :ok and skips work when no API key is configured" do
Application.delete_env(:towerops, :openweathermap_api_key)
assert :ok = WeatherSyncWorker.perform(%Oban.Job{})
end
test "returns :ok and skips work when API key is empty string" do
Application.put_env(:towerops, :openweathermap_api_key, "")
assert :ok = WeatherSyncWorker.perform(%Oban.Job{})
end
test "returns :ok with no sites when API key is set" do
# No sites with coordinates exist; sync_all_sites should no-op happily.
Application.put_env(:towerops, :openweathermap_api_key, "test-key-not-real")
# Stub the Weather + Sites HTTP boundary by ensuring there are no sites with
# coordinates so fetch_and_store is never called. The worker still goes
# through cleanup_old_observations and schedule_next.
assert :ok = WeatherSyncWorker.perform(%Oban.Job{})
end
end
end

View file

@ -0,0 +1,501 @@
defmodule ToweropsWeb.GraphQL.Resolvers.AuthenticatedTest do
@moduledoc """
Direct invocation tests for happy and not-found paths of GraphQL resolvers.
Skips the Absinthe pipeline so we can test resolver logic in isolation.
"""
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.AgentsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Repo
alias ToweropsWeb.GraphQL.Resolvers
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{user: user, organization: org, ctx: %{context: %{organization_id: org.id, user: user}}}
end
describe "Activity.list/3" do
test "returns ok-tuple for authenticated context", %{ctx: ctx} do
assert {:ok, items} = Resolvers.Activity.list(nil, %{}, ctx)
assert is_list(items)
end
test "ignores invalid type strings", %{ctx: ctx} do
assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "definitely_not_an_atom_xyz"}, ctx)
end
test "accepts known-existing atom type", %{ctx: ctx} do
_ = :alert_created
assert {:ok, _items} = Resolvers.Activity.list(nil, %{type: "alert_created"}, ctx)
end
end
describe "Agent.list/3 and create/3 / delete/3" do
test "lists agent tokens for the org", %{organization: org, ctx: ctx} do
_token = agent_token_fixture(org.id, "Foo")
assert {:ok, agents} = Resolvers.Agent.list(nil, %{}, ctx)
assert Enum.any?(agents, &(&1.name == "Foo"))
end
test "creates a new agent token and returns the raw token", %{ctx: ctx} do
assert {:ok, %{token: raw, name: name}} = Resolvers.Agent.create(nil, %{name: "Bar"}, ctx)
assert is_binary(raw) and raw != ""
assert name == "Bar"
end
test "returns 'Agent not found' for unknown id", %{ctx: ctx} do
assert {:error, "Agent not found"} =
Resolvers.Agent.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "delete on unknown id returns not-found error", %{ctx: ctx} do
assert {:error, "Agent not found"} =
Resolvers.Agent.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
describe "Organization.list_mine/3 and get/3 and update/3" do
test "list_mine includes the user's org", %{user: user, organization: org} do
ctx = %{context: %{user: user}}
assert {:ok, orgs} = Resolvers.Organization.list_mine(nil, %{}, ctx)
assert Enum.any?(orgs, &(&1.id == org.id))
end
test "get/3 returns the org from context", %{ctx: ctx, organization: org} do
assert {:ok, fetched} = Resolvers.Organization.get(nil, %{}, ctx)
assert fetched.id == org.id
end
test "update/3 with valid input changes the name", %{ctx: ctx} do
assert {:ok, updated} = Resolvers.Organization.update(nil, %{input: %{name: "Renamed Co"}}, ctx)
assert updated.name == "Renamed Co"
end
test "update/3 with invalid input returns an error string", %{ctx: ctx} do
assert {:error, msg} = Resolvers.Organization.update(nil, %{input: %{name: "x"}}, ctx)
assert is_binary(msg)
end
end
describe "Site resolvers" do
test "list/3 returns sites for org, get/3 returns one, create/3 succeeds, update/3 + delete/3 work", %{
organization: org,
ctx: ctx
} do
device = device_fixture(%{organization_id: org.id})
site_id = device.site_id
assert {:ok, sites} = Resolvers.Site.list(nil, %{}, ctx)
assert Enum.any?(sites, &(&1.id == site_id))
assert {:ok, fetched} = Resolvers.Site.get(nil, %{id: site_id}, ctx)
assert fetched.id == site_id
assert {:ok, created} =
Resolvers.Site.create(nil, %{input: %{name: "New Site"}}, ctx)
assert created.name == "New Site"
assert {:ok, updated} =
Resolvers.Site.update(nil, %{id: created.id, input: %{name: "Renamed"}}, ctx)
assert updated.name == "Renamed"
assert {:ok, %{success: true}} = Resolvers.Site.delete(nil, %{id: created.id}, ctx)
refute Repo.get(Towerops.Sites.Site, created.id)
end
test "get/3 with unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Site not found"} =
Resolvers.Site.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "update/3 with unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Site not found"} =
Resolvers.Site.update(
nil,
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
ctx
)
end
test "delete/3 with unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Site not found"} =
Resolvers.Site.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
describe "Integration resolvers" do
test "list/3 returns []", %{ctx: ctx} do
assert {:ok, []} = Resolvers.Integration.list(nil, %{}, ctx)
end
test "update on unknown id returns 'Integration not found'", %{ctx: ctx} do
assert {:error, "Integration not found"} =
Resolvers.Integration.update(
nil,
%{id: Ecto.UUID.generate(), input: %{name: "x"}},
ctx
)
end
test "delete on unknown id returns 'Integration not found'", %{ctx: ctx} do
assert {:error, "Integration not found"} =
Resolvers.Integration.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "test_connection on unknown id returns 'Integration not found'", %{ctx: ctx} do
assert {:error, "Integration not found"} =
Resolvers.Integration.test_connection(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
describe "MaintenanceWindow resolvers" do
test "list/3 returns []", %{ctx: ctx} do
assert {:ok, []} = Resolvers.MaintenanceWindow.list(nil, %{}, ctx)
end
test "list/3 honors filter args", %{ctx: ctx} do
for filter <- ["active", "upcoming", "past", "everything"] do
assert {:ok, _} = Resolvers.MaintenanceWindow.list(nil, %{filter: filter}, ctx)
end
end
test "get/3 unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Maintenance window not found"} =
Resolvers.MaintenanceWindow.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "update on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Maintenance window not found"} =
Resolvers.MaintenanceWindow.update(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Maintenance window not found"} =
Resolvers.MaintenanceWindow.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create + get + update + delete happy path", %{ctx: ctx} do
starts = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
ends = DateTime.add(starts, 7200, :second)
assert {:ok, window} =
Resolvers.MaintenanceWindow.create(
nil,
%{
input: %{
name: "Smoke Window",
starts_at: starts,
ends_at: ends,
suppress_alerts: true
}
},
ctx
)
assert {:ok, fetched} = Resolvers.MaintenanceWindow.get(nil, %{id: window.id}, ctx)
assert fetched.id == window.id
assert {:ok, updated} =
Resolvers.MaintenanceWindow.update(
nil,
%{id: window.id, input: %{name: "Renamed"}},
ctx
)
assert updated.name == "Renamed"
assert {:ok, %{success: true}} =
Resolvers.MaintenanceWindow.delete(nil, %{id: window.id}, ctx)
end
test "create with invalid input returns formatted error", %{ctx: ctx} do
assert {:error, msg} =
Resolvers.MaintenanceWindow.create(nil, %{input: %{}}, ctx)
assert is_binary(msg)
end
end
describe "Alert resolvers — happy paths" do
test "list returns inserted alerts; get/3 fetches by id", %{organization: org, ctx: ctx} do
device = device_fixture(%{organization_id: org.id})
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "down"
})
assert {:ok, alerts} = Resolvers.Alert.list(nil, %{}, ctx)
assert Enum.any?(alerts, &(&1.id == alert.id))
assert {:ok, fetched} = Resolvers.Alert.get(nil, %{id: alert.id}, ctx)
assert fetched.id == alert.id
end
test "acknowledge transitions an alert and returns it", %{organization: org, ctx: ctx} do
device = device_fixture(%{organization_id: org.id})
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.truncate(DateTime.utc_now(), :second)
})
assert {:ok, acked} = Resolvers.Alert.acknowledge(nil, %{id: alert.id}, ctx)
assert acked.id == alert.id
assert acked.acknowledged_at
end
test "resolve_alert transitions and returns it", %{organization: org, ctx: ctx} do
device = device_fixture(%{organization_id: org.id})
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.truncate(DateTime.utc_now(), :second)
})
assert {:ok, resolved} = Resolvers.Alert.resolve_alert(nil, %{id: alert.id}, ctx)
assert resolved.resolved_at
end
end
describe "EscalationPolicy resolvers (not-found paths)" do
test "get on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Escalation policy not found"} =
Resolvers.EscalationPolicy.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "update on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Escalation policy not found"} =
Resolvers.EscalationPolicy.update(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Escalation policy not found"} =
Resolvers.EscalationPolicy.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create_rule with unknown policy returns not-found", %{ctx: ctx} do
assert {:error, "Escalation policy not found"} =
Resolvers.EscalationPolicy.create_rule(
nil,
%{escalation_policy_id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "update_rule on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Rule not found"} =
Resolvers.EscalationPolicy.update_rule(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete_rule on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Rule not found"} =
Resolvers.EscalationPolicy.delete_rule(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "delete_target on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Target not found"} =
Resolvers.EscalationPolicy.delete_target(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
describe "Schedule resolvers (not-found paths)" do
test "get on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "on_call with unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.on_call(nil, %{schedule_id: Ecto.UUID.generate()}, ctx)
end
test "update on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.update(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create_layer with unknown schedule returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.create_layer(
nil,
%{schedule_id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete_layer on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Layer not found"} =
Resolvers.Schedule.delete_layer(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "remove_member on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Member not found"} =
Resolvers.Schedule.remove_member(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "create_override with unknown schedule returns not-found", %{ctx: ctx} do
assert {:error, "Schedule not found"} =
Resolvers.Schedule.create_override(
nil,
%{schedule_id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "delete_override on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Override not found"} =
Resolvers.Schedule.delete_override(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
describe "Member resolvers (error paths)" do
test "remove on unknown user returns 'Member not found'", %{ctx: ctx} do
assert {:error, "Member not found"} =
Resolvers.Member.remove(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "update_role on unknown user returns 'Member not found'", %{ctx: ctx} do
assert {:error, "Member not found"} =
Resolvers.Member.update_role(
nil,
%{id: Ecto.UUID.generate(), role: "technician"},
ctx
)
end
test "cancel_invitation on unknown id returns 'Invitation not found'", %{ctx: ctx} do
assert {:error, "Invitation not found"} =
Resolvers.Member.cancel_invitation(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "list/3 returns owner as a member", %{ctx: ctx, user: user} do
assert {:ok, members} = Resolvers.Member.list(nil, %{}, ctx)
assert Enum.any?(members, &(&1.user_id == user.id))
end
end
describe "Device resolvers" do
test "list/3 with no devices returns []", %{ctx: ctx} do
assert {:ok, []} = Resolvers.Device.list(nil, %{}, ctx)
end
test "get/3 returns the device", %{organization: org, ctx: ctx} do
d = device_fixture(%{organization_id: org.id})
assert {:ok, found} = Resolvers.Device.get(nil, %{id: d.id}, ctx)
assert found.id == d.id
end
test "get on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "delete on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.delete(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "update on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.update(
nil,
%{id: Ecto.UUID.generate(), input: %{}},
ctx
)
end
test "metrics on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.metrics(nil, %{device_id: Ecto.UUID.generate()}, ctx)
end
test "interfaces on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Device not found"} =
Resolvers.Device.interfaces(nil, %{device_id: Ecto.UUID.generate()}, ctx)
end
test "interfaces returns [] when device has no snmp_device", %{organization: org, ctx: ctx} do
d = device_fixture(%{organization_id: org.id})
assert {:ok, []} = Resolvers.Device.interfaces(nil, %{device_id: d.id}, ctx)
end
test "metrics returns [] when device has no checks", %{organization: org, ctx: ctx} do
d = device_fixture(%{organization_id: org.id})
assert {:ok, []} =
Resolvers.Device.metrics(
nil,
%{device_id: d.id, time_range: "12h", sensor_type: nil},
ctx
)
end
test "metrics with malformed time_range falls back to 24h", %{organization: org, ctx: ctx} do
d = device_fixture(%{organization_id: org.id})
assert {:ok, []} =
Resolvers.Device.metrics(
nil,
%{device_id: d.id, time_range: "garbage"},
ctx
)
end
end
describe "Alert resolvers" do
test "list/3 returns []", %{ctx: ctx} do
assert {:ok, []} = Resolvers.Alert.list(nil, %{}, ctx)
end
test "get on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Alert not found"} =
Resolvers.Alert.get(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "acknowledge on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Alert not found"} =
Resolvers.Alert.acknowledge(nil, %{id: Ecto.UUID.generate()}, ctx)
end
test "resolve_alert on unknown id returns not-found", %{ctx: ctx} do
assert {:error, "Alert not found"} =
Resolvers.Alert.resolve_alert(nil, %{id: Ecto.UUID.generate()}, ctx)
end
end
end

View file

@ -0,0 +1,323 @@
defmodule ToweropsWeb.GraphQL.Resolvers.UnauthenticatedTest do
@moduledoc """
Verifies that every public resolver function returns an authentication error
when invoked without an `organization_id` in the resolution context.
Catches accidental drift where someone adds a new resolver clause that
accepts requests without auth.
"""
use ExUnit.Case, async: true
alias ToweropsWeb.GraphQL.Resolvers
@auth_error {:error, "Authentication required"}
@no_auth %{}
describe "Activity" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Activity.list(nil, %{}, @no_auth)
end
end
describe "Agent" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Agent.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Agent.get(nil, %{id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.Agent.create(nil, %{name: "x"}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.Agent.delete(nil, %{id: "x"}, @no_auth)
end
end
describe "Alert" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Alert.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Alert.get(nil, %{id: "x"}, @no_auth)
end
test "acknowledge/3 unauthenticated" do
assert @auth_error = Resolvers.Alert.acknowledge(nil, %{id: "x"}, @no_auth)
end
test "resolve_alert/3 unauthenticated" do
assert @auth_error = Resolvers.Alert.resolve_alert(nil, %{id: "x"}, @no_auth)
end
end
describe "Device" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Device.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Device.get(nil, %{id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.Device.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error = Resolvers.Device.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.Device.delete(nil, %{id: "x"}, @no_auth)
end
test "metrics/3 unauthenticated" do
assert @auth_error = Resolvers.Device.metrics(nil, %{device_id: "x"}, @no_auth)
end
test "interfaces/3 unauthenticated" do
assert @auth_error = Resolvers.Device.interfaces(nil, %{device_id: "x"}, @no_auth)
end
end
describe "EscalationPolicy" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.get(nil, %{id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.delete(nil, %{id: "x"}, @no_auth)
end
test "create_rule/3 unauthenticated" do
assert @auth_error =
Resolvers.EscalationPolicy.create_rule(
nil,
%{escalation_policy_id: "x", input: %{}},
@no_auth
)
end
test "update_rule/3 unauthenticated" do
assert @auth_error =
Resolvers.EscalationPolicy.update_rule(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete_rule/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.delete_rule(nil, %{id: "x"}, @no_auth)
end
test "create_target/3 unauthenticated" do
assert @auth_error =
Resolvers.EscalationPolicy.create_target(
nil,
%{escalation_rule_id: "x", input: %{}},
@no_auth
)
end
test "delete_target/3 unauthenticated" do
assert @auth_error = Resolvers.EscalationPolicy.delete_target(nil, %{id: "x"}, @no_auth)
end
end
describe "Integration" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Integration.list(nil, %{}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.Integration.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error =
Resolvers.Integration.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.Integration.delete(nil, %{id: "x"}, @no_auth)
end
test "test_connection/3 unauthenticated" do
assert @auth_error = Resolvers.Integration.test_connection(nil, %{id: "x"}, @no_auth)
end
end
describe "MaintenanceWindow" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.MaintenanceWindow.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.MaintenanceWindow.get(nil, %{id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error =
Resolvers.MaintenanceWindow.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error =
Resolvers.MaintenanceWindow.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.MaintenanceWindow.delete(nil, %{id: "x"}, @no_auth)
end
end
describe "Member" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Member.list(nil, %{}, @no_auth)
end
test "invite/3 unauthenticated" do
assert @auth_error = Resolvers.Member.invite(nil, %{email: "x"}, @no_auth)
end
test "cancel_invitation/3 unauthenticated" do
assert @auth_error = Resolvers.Member.cancel_invitation(nil, %{id: "x"}, @no_auth)
end
test "remove/3 unauthenticated" do
assert @auth_error = Resolvers.Member.remove(nil, %{id: "x"}, @no_auth)
end
test "update_role/3 unauthenticated" do
assert @auth_error =
Resolvers.Member.update_role(nil, %{id: "x", role: "admin"}, @no_auth)
end
end
describe "Organization" do
test "list_mine/3 unauthenticated" do
assert @auth_error = Resolvers.Organization.list_mine(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Organization.get(nil, %{}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error = Resolvers.Organization.update(nil, %{input: %{}}, @no_auth)
end
end
describe "Schedule" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.get(nil, %{id: "x"}, @no_auth)
end
test "on_call/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.on_call(nil, %{schedule_id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error =
Resolvers.Schedule.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.delete(nil, %{id: "x"}, @no_auth)
end
test "create_layer/3 unauthenticated" do
assert @auth_error =
Resolvers.Schedule.create_layer(
nil,
%{schedule_id: "x", input: %{}},
@no_auth
)
end
test "update_layer/3 unauthenticated" do
assert @auth_error =
Resolvers.Schedule.update_layer(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete_layer/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.delete_layer(nil, %{id: "x"}, @no_auth)
end
test "add_member/3 unauthenticated" do
assert @auth_error =
Resolvers.Schedule.add_member(
nil,
%{layer_id: "x", user_id: "u", position: 1},
@no_auth
)
end
test "remove_member/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.remove_member(nil, %{id: "x"}, @no_auth)
end
test "create_override/3 unauthenticated" do
assert @auth_error =
Resolvers.Schedule.create_override(
nil,
%{schedule_id: "x", input: %{}},
@no_auth
)
end
test "delete_override/3 unauthenticated" do
assert @auth_error = Resolvers.Schedule.delete_override(nil, %{id: "x"}, @no_auth)
end
end
describe "Site" do
test "list/3 unauthenticated" do
assert @auth_error = Resolvers.Site.list(nil, %{}, @no_auth)
end
test "get/3 unauthenticated" do
assert @auth_error = Resolvers.Site.get(nil, %{id: "x"}, @no_auth)
end
test "create/3 unauthenticated" do
assert @auth_error = Resolvers.Site.create(nil, %{input: %{}}, @no_auth)
end
test "update/3 unauthenticated" do
assert @auth_error = Resolvers.Site.update(nil, %{id: "x", input: %{}}, @no_auth)
end
test "delete/3 unauthenticated" do
assert @auth_error = Resolvers.Site.delete(nil, %{id: "x"}, @no_auth)
end
end
describe "Helpers" do
test "authentication_error/0 returns canonical tuple" do
assert Resolvers.Helpers.authentication_error() == @auth_error
end
end
end

View file

@ -0,0 +1,230 @@
defmodule ToweropsWeb.IntegrationsSmokeTest do
@moduledoc """
Smoke tests for low-coverage org-scoped integration LiveViews and device-scoped
LiveViews. These tests exercise the mount/render/handle_params paths without
asserting deep behavior, which is sufficient for coverage of happy-path code.
"""
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Towerops.Devices.MikrotikBackups
alias Towerops.MaintenanceFixtures
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Smoke Org"}, user.id)
%{organization: organization}
end
defp assert_mount_or_redirect(result) do
assert match?({:ok, _, _}, result) or
match?({:error, {:redirect, _}}, result) or
match?({:error, {:live_redirect, _}}, result)
end
describe "smoke: gaiia integration LiveViews" do
test "GET /orgs/:slug/settings/integrations/gaiia/mapping", %{conn: conn, organization: org} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/mapping?tab=sites", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?tab=sites"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/mapping?filter=linked", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=linked"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/mapping?filter=unlinked", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/mapping?filter=unlinked"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation?tab=devices", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=devices"))
end
test "GET /orgs/:slug/settings/integrations/gaiia/reconciliation?tab=sites", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/gaiia/reconciliation?tab=sites"))
end
end
describe "smoke: preseem integration LiveViews" do
test "GET /orgs/:slug/settings/integrations/preseem/devices", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices"))
end
test "GET /orgs/:slug/settings/integrations/preseem/devices?filter=matched", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=matched"))
end
test "GET /orgs/:slug/settings/integrations/preseem/devices?filter=unmatched", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched"))
end
test "GET /orgs/:slug/settings/integrations/preseem/insights", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights"))
end
test "GET /orgs/:slug/settings/integrations/preseem/insights?status=resolved", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?status=resolved"))
end
test "GET /orgs/:slug/settings/integrations/preseem/insights?type=qoe&urgency=high", %{
conn: conn,
organization: org
} do
assert_mount_or_redirect(
live(
conn,
~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?type=qoe&urgency=high"
)
)
end
end
describe "smoke: user settings sub-tabs (SessionManager / TotpManager)" do
test "GET /users/settings (default index)", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings"))
end
test "GET /users/settings?tab=sessions", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=sessions"))
end
test "GET /users/settings?tab=security", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=security"))
end
test "GET /users/settings?tab=personal", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=personal"))
end
test "GET /users/settings?tab=account", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=account"))
end
test "GET /users/settings?tab=notifications", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=notifications"))
end
test "GET /users/settings?tab=api", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/users/settings?tab=api"))
end
end
describe "smoke: device-scoped LiveViews (ConfigTimeline / Compare)" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Site-#{System.unique_integer([:positive])}",
organization_id: org.id
})
device =
Towerops.DevicesFixtures.device_fixture(%{
organization_id: org.id,
site_id: site.id
})
%{site: site, device: device}
end
test "GET /devices/:device_id/config-timeline", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline"))
end
test "GET /devices/:device_id/config-timeline?range=24h", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline?range=24h"))
end
test "GET /devices/:device_id/config-timeline?range=30d", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline?range=30d"))
end
test "GET /devices/:device_id/backups/compare without backup params redirects", %{
conn: conn,
device: device
} do
# Compare requires backup_a + backup_b query params; without them it redirects.
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/backups/compare"))
end
test "GET /devices/:device_id/backups/compare with two backups", %{
conn: conn,
device: device
} do
{:ok, backup_a} =
MikrotikBackups.create_backup(device.id, "/system identity\nset name=router-a", "manual")
{:ok, backup_b} =
MikrotikBackups.create_backup(device.id, "/system identity\nset name=router-b", "manual")
assert_mount_or_redirect(
live(
conn,
~p"/devices/#{device.id}/backups/compare?backup_a=#{backup_a.id}&backup_b=#{backup_b.id}"
)
)
end
end
describe "smoke: maintenance show extra params" do
setup %{organization: org, user: user} do
window =
MaintenanceFixtures.maintenance_window_fixture(%{
name: "Smoke Window 2",
organization_id: org.id,
created_by_id: user.id
})
%{window: window}
end
test "GET /maintenance/:id with tab param", %{conn: conn, window: window} do
assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}?tab=devices"))
end
test "GET /maintenance/:id/edit with section", %{conn: conn, window: window} do
assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}/edit"))
end
end
end

View file

@ -219,5 +219,132 @@ defmodule ToweropsWeb.LiveViewSmokeTest do
test "GET /maintenance?filter=upcoming", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=upcoming"))
end
test "GET /maintenance?filter=past", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/maintenance?filter=past"))
end
end
describe "smoke: maintenance show & edit" do
setup %{organization: org, user: user} do
window =
Towerops.MaintenanceFixtures.maintenance_window_fixture(%{
name: "Smoke Window",
organization_id: org.id,
created_by_id: user.id
})
%{window: window}
end
test "GET /maintenance/:id", %{conn: conn, window: window} do
assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}"))
end
test "GET /maintenance/:id/edit", %{conn: conn, window: window} do
assert_mount_or_redirect(live(conn, ~p"/maintenance/#{window.id}/edit"))
end
end
describe "smoke: agents specific paths" do
setup %{organization: org} do
{:ok, agent_token, _raw} =
Towerops.Agents.create_agent_token(org.id, "Smoke Agent")
%{agent_token: agent_token}
end
test "GET /agents/:id", %{conn: conn, agent_token: token} do
assert_mount_or_redirect(live(conn, ~p"/agents/#{token.id}"))
end
test "GET /agents/:id/edit", %{conn: conn, agent_token: token} do
assert_mount_or_redirect(live(conn, ~p"/agents/#{token.id}/edit"))
end
end
describe "smoke: org-scoped settings" do
test "GET /orgs/:slug/settings", %{conn: conn, organization: org} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings"))
end
test "GET /orgs/:slug/settings/integrations", %{conn: conn, organization: org} do
assert_mount_or_redirect(live(conn, ~p"/orgs/#{org.slug}/settings/integrations"))
end
end
describe "smoke: more top-level pages" do
test "GET /agents", %{conn: conn} do
assert {:ok, _, _} = live(conn, ~p"/agents")
end
test "GET /sites", %{conn: conn} do
assert {:ok, _, _} = live(conn, ~p"/sites")
end
test "GET /devices", %{conn: conn} do
assert {:ok, _, _} = live(conn, ~p"/devices")
end
test "GET /mobile/qr-login", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/mobile/qr-login"))
end
test "GET /account/activity", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/account/activity"))
end
test "GET /account/totp-enrollment", %{conn: conn} do
assert_mount_or_redirect(live(conn, ~p"/account/totp-enrollment"))
end
end
describe "smoke: device-related deep paths" do
setup %{organization: org} do
device =
Towerops.DevicesFixtures.device_fixture(%{
organization_id: org.id,
name: "Smoke Device"
})
%{device: device}
end
test "GET /devices/:id", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}"))
end
test "GET /devices/:id/edit", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/edit"))
end
test "GET /devices/:id/config-timeline", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/config-timeline"))
end
test "GET /devices/:id/graph/temperature", %{conn: conn, device: device} do
assert_mount_or_redirect(live(conn, ~p"/devices/#{device.id}/graph/temperature"))
end
end
describe "smoke: schedules deep paths" do
setup %{organization: org} do
{:ok, schedule} =
Towerops.OnCall.create_schedule(%{
name: "Smoke Schedule",
organization_id: org.id,
timezone: "America/Chicago"
})
%{schedule: schedule}
end
test "GET /schedules/:id", %{conn: conn, schedule: schedule} do
assert_mount_or_redirect(live(conn, ~p"/schedules/#{schedule.id}"))
end
test "GET /schedules/:id/edit", %{conn: conn, schedule: schedule} do
assert_mount_or_redirect(live(conn, ~p"/schedules/#{schedule.id}/edit"))
end
end
end