test: add coverage for query modules, sync paths, agent discovery

This commit is contained in:
Graham McIntire 2026-05-05 10:45:55 -05:00
parent fde6158251
commit cea0912ed1
5 changed files with 537 additions and 0 deletions

View file

@ -0,0 +1,75 @@
defmodule Towerops.Agents.AgentTokenQueryTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Agents
alias Towerops.Agents.AgentToken
alias Towerops.Agents.AgentTokenQuery
alias Towerops.Repo
setup do
user = user_fixture()
org = organization_fixture(user.id)
{:ok, t1, _raw1} = Agents.create_agent_token(org.id, "Token One")
{:ok, t2, _raw2} = Agents.create_agent_token(org.id, "Token Two")
%{org: org, t1: t1, t2: t2}
end
describe "base/0" do
test "is the AgentToken schema" do
assert AgentTokenQuery.base() == AgentToken
end
end
describe "for_organization/2" do
test "scopes to a single organization", %{org: org} do
results =
AgentTokenQuery.base()
|> AgentTokenQuery.for_organization(org.id)
|> Repo.all()
assert length(results) == 2
assert Enum.all?(results, &(&1.organization_id == org.id))
end
test "returns [] for unknown org" do
assert Ecto.UUID.generate() |> AgentTokenQuery.for_organization() |> Repo.all() == []
end
end
describe "enabled/1" do
test "returns only enabled tokens", %{org: org, t1: t1} do
# Disable t1
{:ok, _} =
t1
|> Ecto.Changeset.change(enabled: false)
|> Repo.update()
results =
AgentTokenQuery.base()
|> AgentTokenQuery.for_organization(org.id)
|> AgentTokenQuery.enabled()
|> Repo.all()
assert length(results) == 1
assert Enum.all?(results, & &1.enabled)
end
end
describe "excluding_cloud_pollers/1" do
test "returns all org-scoped tokens (none are cloud pollers in this fixture)", %{org: org} do
results =
AgentTokenQuery.base()
|> AgentTokenQuery.for_organization(org.id)
|> AgentTokenQuery.excluding_cloud_pollers()
|> Repo.all()
assert length(results) == 2
assert Enum.all?(results, &(&1.is_cloud_poller == false))
end
end
end

View file

@ -0,0 +1,126 @@
defmodule Towerops.Devices.DeviceQueryTest do
use Towerops.DataCase, async: true
use ExUnitProperties
import Towerops.DevicesFixtures
alias Towerops.Devices.Device
alias Towerops.Devices.DeviceQuery
alias Towerops.Repo
describe "base/0" do
test "returns the Device schema" do
assert DeviceQuery.base() == Device
end
end
describe "for_organization/2" do
test "scopes to a single organization" do
d = device_fixture()
_other = device_fixture()
[%{id: id}] =
DeviceQuery.base()
|> DeviceQuery.for_organization(d.organization_id)
|> Repo.all()
assert id == d.id
end
end
describe "for_organizations/2" do
test "matches any of the given org ids" do
d1 = device_fixture()
d2 = device_fixture()
_ = device_fixture()
ids =
[d1.organization_id, d2.organization_id]
|> DeviceQuery.for_organizations()
|> Repo.all()
|> MapSet.new(& &1.id)
assert MapSet.member?(ids, d1.id)
assert MapSet.member?(ids, d2.id)
end
end
describe "for_site/2 and for_sites/2" do
test "for_site narrows to one site" do
d = device_fixture()
_ = device_fixture()
[%{id: id}] = d.site_id |> DeviceQuery.for_site() |> Repo.all()
assert id == d.id
end
test "for_sites matches any in list" do
d1 = device_fixture()
d2 = device_fixture()
ids =
[d1.site_id, d2.site_id]
|> DeviceQuery.for_sites()
|> Repo.all()
|> MapSet.new(& &1.id)
assert ids == MapSet.new([d1.id, d2.id])
end
end
describe "with_status/2" do
test "filters by status" do
d_down = device_fixture(%{status: "down"})
_d_up = device_fixture(%{status: "up"})
[%{id: id}] = "down" |> DeviceQuery.with_status() |> Repo.all()
assert id == d_down.id
end
end
describe "order_by_display/1" do
test "orders by display_order ascending then name" do
d_a = device_fixture(%{name: "Alpha", display_order: 2})
d_b = device_fixture(%{name: "Bravo", display_order: 1})
d_c = device_fixture(%{name: "Charlie", display_order: 1})
ids =
DeviceQuery.base()
|> DeviceQuery.for_organizations([
d_a.organization_id,
d_b.organization_id,
d_c.organization_id
])
|> DeviceQuery.order_by_display()
|> Repo.all()
|> Enum.map(& &1.id)
# display_order: b,c (=1) come before a (=2). Within (=1), alphabetical name
assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_b.id))
assert Enum.find_index(ids, &(&1 == d_a.id)) > Enum.find_index(ids, &(&1 == d_c.id))
end
end
describe "property: composing for_organization with another filter narrows results" do
property "for_organization + with_status returns subset of for_organization" do
check all(status <- StreamData.member_of(["up", "down", "unknown"]), max_runs: 6) do
d = device_fixture(%{status: status})
a =
d.organization_id
|> DeviceQuery.for_organization()
|> Repo.all()
|> MapSet.new(& &1.id)
b =
d.organization_id
|> DeviceQuery.for_organization()
|> DeviceQuery.with_status(status)
|> Repo.all()
|> MapSet.new(& &1.id)
assert MapSet.subset?(b, a)
end
end
end
end

View file

@ -0,0 +1,69 @@
defmodule Towerops.Snmp.AgentDiscoveryTest do
@moduledoc """
Tests for `Towerops.Snmp.AgentDiscovery.process_agent_discovery/2`.
Covers the public entry point's branches:
* empty `oid_values` map `{:error, :agent_collected_no_data}` (with the
community-obscuring log path executed)
* SNMP-disabled device `{:error, :snmp_not_enabled}` from the underlying
`Discovery.discover_device_with_opts/2`
* happy path: a minimally populated OID map is replayed through the
Replay adapter and produces a `Towerops.Snmp.Device`.
"""
use Towerops.DataCase, async: true
alias Towerops.DevicesFixtures
alias Towerops.Snmp.AgentDiscovery
alias Towerops.Snmp.Device, as: DiscoveredDevice
describe "process_agent_discovery/2 — failure paths" do
test "empty oid_values returns :agent_collected_no_data" do
device = DevicesFixtures.device_fixture()
assert {:error, :agent_collected_no_data} =
AgentDiscovery.process_agent_discovery(device, %{})
end
test "empty oid_values for a device with a short community still returns :agent_collected_no_data" do
# Exercises the `obscure_community/1` else-branch where community is ≤ 3 chars.
device = DevicesFixtures.device_fixture(%{snmp_community: "ab"})
assert {:error, :agent_collected_no_data} =
AgentDiscovery.process_agent_discovery(device, %{})
end
test "snmp-disabled device propagates :snmp_not_enabled from Discovery" do
device = DevicesFixtures.device_fixture(%{snmp_enabled: false})
oid_values = %{"1.3.6.1.2.1.1.1.0" => "Anything"}
assert {:error, :snmp_not_enabled} =
AgentDiscovery.process_agent_discovery(device, oid_values)
end
end
describe "process_agent_discovery/2 — happy path" do
test "replays a minimal OID map through the Discovery pipeline" do
device = DevicesFixtures.device_fixture()
# Minimum sysObjectID + sysDescr + sysUptime so build_device_info / select_profile
# have something to work with. Empty interface table is fine — discovery
# returns an empty list.
oid_values = %{
"1.3.6.1.2.1.1.1.0" => "Test Device",
"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.1",
"1.3.6.1.2.1.1.3.0" => "123456",
"1.3.6.1.2.1.1.4.0" => "admin@test.com",
"1.3.6.1.2.1.1.5.0" => "test-device",
"1.3.6.1.2.1.1.6.0" => "Test Lab"
}
assert {:ok, %DiscoveredDevice{} = discovered} =
AgentDiscovery.process_agent_discovery(device, oid_values)
assert discovered.device_id == device.id
assert is_list(discovered.interfaces)
end
end
end

View file

@ -111,4 +111,44 @@ defmodule Towerops.Uisp.GpsSyncTest do
end
end
end
describe "sync_gps/1" do
test "empty pairs list returns zero counts" do
assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps([])
end
test "skips pairs without GPS coordinates (no metadata or site access)" do
# Without lat/lon in either location.* or gps.* the early-return path skips
# both updates without touching device.metadata, so passing a plain map is safe.
pair = {%{"location" => %{}, "gps" => %{}}, %{metadata: %{}, site_id: nil}}
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
GpsSync.sync_gps([pair])
end
test "skips pairs whose coordinates fail null-island validation" do
pair = {%{"location" => %{"latitude" => 0.0, "longitude" => 0.0}}, %{}}
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
GpsSync.sync_gps([pair])
end
test "skips pairs with nil coordinates" do
pair =
{%{"location" => %{"latitude" => nil, "longitude" => nil}, "gps" => %{}}, %{}}
assert {:ok, %{devices_updated: 0, sites_updated: 0}} =
GpsSync.sync_gps([pair])
end
test "multiple pairs all invalid still aggregates to zero" do
pairs = [
{%{"gps" => %{}}, %{}},
{%{"location" => %{"latitude" => 0.0, "longitude" => 5.0}}, %{}},
{%{"location" => %{"longitude" => -74.0}}, %{}}
]
assert {:ok, %{devices_updated: 0, sites_updated: 0}} = GpsSync.sync_gps(pairs)
end
end
end

View file

@ -0,0 +1,227 @@
defmodule Towerops.Uisp.StatisticsSyncIntegrationTest do
@moduledoc """
DB-backed tests for `Towerops.Uisp.StatisticsSync` covering
`recently_polled?/3` and the `sync_statistics/2` orchestration that
shells out through the UISP HTTP client (stubbed via `Req.Test.stub`).
Pure helper functions are exercised in `statistics_sync_test.exs`.
"""
use Towerops.DataCase, async: true
alias Towerops.AccountsFixtures
alias Towerops.DevicesFixtures
alias Towerops.Integrations
alias Towerops.OrganizationsFixtures
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.SnmpFixtures
alias Towerops.Uisp.Client
alias Towerops.Uisp.StatisticsSync
defp setup_org_and_integration(extra_creds \\ %{}) do
user = AccountsFixtures.user_fixture()
org = OrganizationsFixtures.organization_fixture(user.id)
creds =
Map.merge(
%{"url" => "https://uisp.example.com/nms/api/v2.1", "api_key" => "test-key"},
extra_creds
)
{:ok, integration} =
Integrations.create_integration(org.id, %{
provider: "uisp",
enabled: true,
credentials: creds
})
{org, integration}
end
defp insert_interface(snmp_device_id, attrs \\ %{}) do
base = %{
snmp_device_id: snmp_device_id,
if_index: System.unique_integer([:positive])
}
%Interface{}
|> Interface.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp insert_interface_stat(interface_id, checked_at, attrs \\ %{}) do
base = %{
interface_id: interface_id,
if_in_octets: 0,
if_out_octets: 0,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
checked_at: DateTime.truncate(checked_at, :second)
}
%InterfaceStat{}
|> InterfaceStat.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
describe "recently_polled?/3" do
test "returns false when no readings exist for the device" do
{_org, _integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
_iface = insert_interface(snmp.id)
now = DateTime.utc_now()
# NOTE: StatisticsSync queries Interfaces by `snmp_device_id == device_id`,
# so we mirror that call signature here.
refute StatisticsSync.recently_polled?(snmp.id, "any", now)
end
test "returns true when a reading exists within the dedup window" do
{_org, _integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
iface = insert_interface(snmp.id)
now = DateTime.utc_now()
recent = DateTime.add(now, -30, :second)
_ = insert_interface_stat(iface.id, recent)
assert StatisticsSync.recently_polled?(snmp.id, "any", now)
end
test "returns false when readings exist but only outside the dedup window" do
{_org, _integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
iface = insert_interface(snmp.id)
now = DateTime.utc_now()
old = DateTime.add(now, -10 * 60, :second)
_ = insert_interface_stat(iface.id, old)
refute StatisticsSync.recently_polled?(snmp.id, "any", now)
end
test "returns false for a different device id even if readings exist nearby" do
{_org, _integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
iface = insert_interface(snmp.id)
now = DateTime.utc_now()
_ = insert_interface_stat(iface.id, DateTime.add(now, -10, :second))
other_device = DevicesFixtures.device_fixture()
other_snmp = SnmpFixtures.snmp_device_fixture(%{device: other_device})
refute StatisticsSync.recently_polled?(other_snmp.id, "any", now)
end
end
describe "sync_statistics/2 — empty / error paths" do
test "empty device map returns zero counts without making HTTP calls" do
{_org, integration} = setup_org_and_integration()
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
StatisticsSync.sync_statistics(integration, %{})
end
test "HTTP errors are swallowed, leaving counts at zero" do
{_org, integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
Req.Test.stub(Client, fn conn ->
conn
|> Plug.Conn.put_status(404)
|> Req.Test.json(%{"error" => "not found"})
end)
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device})
end
test "non-list response body is ignored without crashing" do
{_org, integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, %{"unexpected" => "shape"})
end)
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
StatisticsSync.sync_statistics(integration, %{"uisp-device-1" => device})
end
test "stats with missing/invalid timestamps are counted as skipped_dedup" do
{_org, integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, [
%{"interfaceName" => "eth0", "timestamp" => nil, "receive" => 100, "transmit" => 200},
%{"interfaceName" => "eth1", "timestamp" => "garbage", "receive" => 1, "transmit" => 2}
])
end)
assert {:ok, %{inserted: 0, skipped_dedup: 2}} =
StatisticsSync.sync_statistics(integration, %{"uisp-1" => device})
end
end
describe "sync_statistics/2 — valid stats" do
test "no primary interface (none for device.id) leaves counts at zero" do
# StatisticsSync queries Interfaces by `snmp_device_id == device.id`,
# so an Interface attached to the Device's Snmp.Device id is invisible
# to the lookup — the insert path returns {:error, :no_interface}.
{_org, integration} = setup_org_and_integration()
device = DevicesFixtures.device_fixture()
snmp = SnmpFixtures.snmp_device_fixture(%{device: device})
_iface = insert_interface(snmp.id, %{if_index: 1})
ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
Req.Test.stub(Client, fn conn ->
Req.Test.json(conn, [
%{
"interfaceName" => "eth0",
"timestamp" => ts,
"receive" => 1024,
"transmit" => 2048,
"errors" => 5
}
])
end)
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
StatisticsSync.sync_statistics(integration, %{"uisp-1" => device})
assert Repo.all(InterfaceStat) == []
end
test "non-list response from one device + valid (timestamped) stat from another" do
{_org, integration} = setup_org_and_integration()
d1 = DevicesFixtures.device_fixture()
d2 = DevicesFixtures.device_fixture()
Req.Test.stub(Client, fn conn ->
cond do
String.contains?(conn.request_path, "/devices/uisp-1/statistics") ->
Req.Test.json(conn, %{"unexpected" => "shape"})
String.contains?(conn.request_path, "/devices/uisp-2/statistics") ->
ts = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
Req.Test.json(conn, [%{"timestamp" => ts}])
end
end)
assert {:ok, %{inserted: 0, skipped_dedup: 0}} =
StatisticsSync.sync_statistics(integration, %{
"uisp-1" => d1,
"uisp-2" => d2
})
end
end
end