towerops/test/towerops/snmp_test.exs

3081 lines
89 KiB
Elixir

defmodule Towerops.SnmpTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.ArpEntry
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.MacAddress
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
alias Towerops.Snmp.WirelessClient
alias Towerops.Snmp.WirelessClientReading
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.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_name: "test-router",
sys_descr: "Test Device"
})
|> Repo.insert!()
%{device: device, snmp_device: snmp_device, organization: organization}
end
describe "test_connection/4" do
import Mox
setup :verify_on_exit!
test "delegates to Client.test_connection" do
expect(SnmpMock, :get, fn _, _, _ ->
{:ok, 12_345}
end)
result = Snmp.test_connection("192.168.1.1", "public", "2c")
case result do
{:ok, _} -> assert true
{:error, _} -> assert true
end
end
test "accepts custom port" do
expect(SnmpMock, :get, fn _, _, _ ->
{:ok, 12_345}
end)
result = Snmp.test_connection("192.168.1.1", "public", "2c", 1161)
case result do
{:ok, _} -> assert true
{:error, _} -> assert true
end
end
end
describe "get_device/1" do
test "returns device for device_id", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device(device.id)
assert found_device.id == snmp_device.id
assert found_device.sys_name == "test-router"
end
test "returns nil for non-existent device_id" do
assert Snmp.get_device(Ecto.UUID.generate()) == nil
end
end
describe "get_device_with_associations/1" do
test "returns device with preloaded associations", %{device: device, snmp_device: snmp_device} do
found_device = Snmp.get_device_with_associations(device.id)
assert found_device.id == snmp_device.id
assert Ecto.assoc_loaded?(found_device.interfaces)
assert Ecto.assoc_loaded?(found_device.sensors)
end
test "returns nil for non-existent device_id" do
assert Snmp.get_device_with_associations(Ecto.UUID.generate()) == nil
end
end
describe "list_interfaces/1" do
test "returns all interfaces for a device ordered by if_index", %{device: _device, snmp_device: snmp_device} do
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interfaces = Snmp.list_interfaces(snmp_device.id)
assert length(interfaces) == 2
assert hd(interfaces).id == interface2.id
assert List.last(interfaces).id == interface1.id
end
test "returns empty list for device with no interfaces", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_interfaces(snmp_device.id) == []
end
end
describe "list_monitored_interfaces/1" do
test "returns only monitored interfaces", %{device: _device, snmp_device: snmp_device} do
monitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: true
})
|> Repo.insert!()
_unmonitored =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1",
monitored: false
})
|> Repo.insert!()
interfaces = Snmp.list_monitored_interfaces(snmp_device.id)
assert length(interfaces) == 1
assert hd(interfaces).id == monitored.id
end
end
describe "get_interface/1" do
test "returns interface by id", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
found = Snmp.get_interface(interface.id)
assert found.id == interface.id
assert found.if_name == "eth0"
end
test "returns nil for non-existent id" do
assert Snmp.get_interface(Ecto.UUID.generate()) == nil
end
end
describe "update_interface/2" do
test "updates interface attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
monitored: false
})
|> Repo.insert!()
{:ok, updated} = Snmp.update_interface(interface, %{monitored: true})
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
{:error, changeset} = Snmp.update_interface(interface, %{if_index: nil})
assert changeset.errors[:if_index]
end
end
describe "list_sensors/1" do
test "returns all sensors for a device ordered by type and index", %{device: _device, snmp_device: snmp_device} do
sensor1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
sensors = Snmp.list_sensors(snmp_device.id)
assert length(sensors) == 2
assert sensor1.id in Enum.map(sensors, & &1.id)
assert sensor2.id in Enum.map(sensors, & &1.id)
end
test "returns empty list for device with no sensors", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_sensors(snmp_device.id) == []
end
end
describe "list_monitored_sensors/1" do
test "returns only monitored sensors", %{device: _device, snmp_device: snmp_device} do
monitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
monitored: true
})
|> Repo.insert!()
_unmonitored =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5",
monitored: false
})
|> Repo.insert!()
sensors = Snmp.list_monitored_sensors(snmp_device.id)
assert length(sensors) == 1
assert hd(sensors).id == monitored.id
end
end
describe "list_sensors_by_type/1" do
test "groups sensors by type", %{device: _device, snmp_device: snmp_device} do
_temp1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
_temp2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
_power =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "3",
sensor_oid: "1.2.3.6"
})
|> Repo.insert!()
grouped = Snmp.list_sensors_by_type(snmp_device.id)
assert Map.has_key?(grouped, "temperature")
assert Map.has_key?(grouped, "power")
assert length(grouped["temperature"]) == 2
assert length(grouped["power"]) == 1
end
test "returns empty map for device with no sensors", %{device: _device, snmp_device: snmp_device} do
assert Snmp.list_sensors_by_type(snmp_device.id) == %{}
end
end
describe "get_sensor/1" do
test "returns sensor by id", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
found = Snmp.get_sensor(sensor.id)
assert found.id == sensor.id
assert found.sensor_type == "temperature"
end
test "returns nil for non-existent id" do
assert Snmp.get_sensor(Ecto.UUID.generate()) == nil
end
end
describe "update_sensor/2" do
test "updates sensor attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4",
monitored: false
})
|> Repo.insert!()
{:ok, updated} = Snmp.update_sensor(sensor, %{monitored: true})
assert updated.monitored == true
end
test "returns error for invalid attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
{:error, changeset} = Snmp.update_sensor(sensor, %{sensor_oid: nil})
assert changeset.errors[:sensor_oid]
end
end
describe "get_sensor_readings/2" do
test "returns recent readings for a sensor", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
for i <- 1..5 do
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0 + i,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_sensor_readings(sensor.id)
assert length(readings) == 5
end
test "respects limit option", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
for i <- 1..10 do
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0 + i,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_sensor_readings(sensor.id, limit: 3)
assert length(readings) == 3
end
test "respects since option", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: past
})
|> Repo.insert!()
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
readings = Snmp.get_sensor_readings(sensor.id, since: since_time)
assert length(readings) == 1
assert hd(readings).value == 25.0
end
test "returns empty list for sensor with no readings", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
assert Snmp.get_sensor_readings(sensor.id) == []
end
end
describe "get_latest_sensor_reading/1" do
test "returns most recent reading", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
old_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_sensor_reading(sensor.id)
assert latest.id == new_reading.id
refute latest.id == old_reading.id
end
test "returns nil for sensor with no readings", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
assert Snmp.get_latest_sensor_reading(sensor.id) == nil
end
end
describe "get_interface_stats/2" do
test "returns recent stats for an interface", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
for i <- 1..5 do
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: i * 1000,
if_out_octets: i * 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
stats = Snmp.get_interface_stats(interface.id)
assert length(stats) == 5
end
test "respects limit option", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
for i <- 1..10 do
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: i * 1000,
if_out_octets: i * 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
stats = Snmp.get_interface_stats(interface.id, limit: 3)
assert length(stats) == 3
end
test "respects since option", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: past
})
|> Repo.insert!()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
stats = Snmp.get_interface_stats(interface.id, since: since_time)
assert length(stats) == 1
assert hd(stats).if_in_octets == 5000
end
test "returns empty list for interface with no stats", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
assert Snmp.get_interface_stats(interface.id) == []
end
end
describe "get_latest_interface_stat/1" do
test "returns most recent stat", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_interface_stat(interface.id)
assert latest.id == new_stat.id
refute latest.id == old_stat.id
end
test "returns nil for interface with no stats", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
assert Snmp.get_latest_interface_stat(interface.id) == nil
end
end
describe "create_sensor_reading/1" do
test "creates a sensor reading with valid attributes", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
{:ok, reading} =
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 25.5,
status: "ok",
checked_at: DateTime.utc_now()
})
assert reading.sensor_id == sensor.id
assert reading.value == 25.5
assert reading.status == "ok"
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_sensor_reading(%{value: "not a number"})
assert changeset.errors[:sensor_id]
end
end
describe "create_interface_stat/1" do
test "creates an interface stat with valid attributes", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
{:ok, stat} =
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
checked_at: DateTime.utc_now()
})
assert stat.interface_id == interface.id
assert stat.if_in_octets == 1000
assert stat.if_out_octets == 2000
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_interface_stat(%{if_in_octets: "not a number"})
assert changeset.errors[:interface_id]
end
end
describe "get_latest_sensor_readings_batch/1" do
test "returns empty map for empty sensor list", %{device: _device, snmp_device: _snmp_device} do
assert Snmp.get_latest_sensor_readings_batch([]) == %{}
end
test "returns readings for multiple sensors", %{device: _device, snmp_device: snmp_device} do
sensor1 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
reading1 =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor1.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
reading2 =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor2.id,
value: 100.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor1.id, sensor2.id])
assert map_size(batch) == 2
assert batch[sensor1.id].id == reading1.id
assert batch[sensor2.id].id == reading2.id
end
test "returns only latest reading per sensor", %{device: _device, snmp_device: snmp_device} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
_old_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 20.0,
status: "ok",
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor.id])
assert map_size(batch) == 1
assert batch[sensor.id].id == new_reading.id
assert batch[sensor.id].value == 25.0
end
test "excludes sensors without readings", %{device: _device, snmp_device: snmp_device} do
sensor_with_reading =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
sensor_without_reading =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "power",
sensor_index: "2",
sensor_oid: "1.2.3.5"
})
|> Repo.insert!()
%SensorReading{}
|> SensorReading.changeset(%{
sensor_id: sensor_with_reading.id,
value: 25.0,
status: "ok",
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_sensor_readings_batch([sensor_with_reading.id, sensor_without_reading.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, sensor_with_reading.id)
refute Map.has_key?(batch, sensor_without_reading.id)
end
end
describe "get_latest_interface_stats_batch/1" do
test "returns empty map for empty interface list", %{device: _device, snmp_device: _snmp_device} do
assert Snmp.get_latest_interface_stats_batch([]) == %{}
end
test "returns stats for multiple interfaces", %{device: _device, snmp_device: snmp_device} do
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
stat1 =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface1.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
stat2 =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface2.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface1.id, interface2.id])
assert map_size(batch) == 2
assert batch[interface1.id].id == stat1.id
assert batch[interface2.id].id == stat2.id
end
test "returns only latest stat per interface", %{device: _device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
_old_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_stat =
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 5000,
if_out_octets: 6000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface.id])
assert map_size(batch) == 1
assert batch[interface.id].id == new_stat.id
assert batch[interface.id].if_in_octets == 5000
end
test "excludes interfaces without stats", %{device: _device, snmp_device: snmp_device} do
interface_with_stat =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface_without_stat =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface_with_stat.id,
if_in_octets: 1000,
if_out_octets: 2000,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_interface_stats_batch([interface_with_stat.id, interface_without_stat.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, interface_with_stat.id)
refute Map.has_key?(batch, interface_without_stat.id)
end
end
describe "list_neighbors/1" do
test "returns all neighbors for a device", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "neighbor-switch",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbors = Snmp.list_neighbors(device.id)
assert length(neighbors) == 1
assert hd(neighbors).id == neighbor.id
assert Ecto.assoc_loaded?(hd(neighbors).interface)
end
test "returns empty list for device with no neighbors", %{device: device, snmp_device: _snmp_device} do
assert Snmp.list_neighbors(device.id) == []
end
test "orders neighbors by protocol and system name", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor_lldp_b =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "switch-b",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbor_cdp_a =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "cdp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_system_name: "router-a",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbor_lldp_a =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "11:22:33:44:55:66",
remote_system_name: "switch-a",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
neighbors = Snmp.list_neighbors(device.id)
assert length(neighbors) == 3
# Should be ordered: cdp first, then lldp alphabetically
assert Enum.at(neighbors, 0).id == neighbor_cdp_a.id
assert Enum.at(neighbors, 1).id == neighbor_lldp_a.id
assert Enum.at(neighbors, 2).id == neighbor_lldp_b.id
end
end
describe "get_neighbor/1" do
test "returns neighbor by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
found = Snmp.get_neighbor(neighbor.id)
assert found.id == neighbor.id
assert found.remote_chassis_id == "00:11:22:33:44:55"
end
test "returns nil for non-existent id" do
assert Snmp.get_neighbor(Ecto.UUID.generate()) == nil
end
end
describe "upsert_neighbor/1" do
test "creates new neighbor when none exists", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
attrs = %{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "new-neighbor",
last_discovered_at: DateTime.utc_now()
}
{:ok, neighbor} = Snmp.upsert_neighbor(attrs)
assert neighbor.remote_system_name == "new-neighbor"
assert neighbor.remote_chassis_id == "00:11:22:33:44:55"
end
test "updates existing neighbor when match found", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
existing =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "old-name",
last_discovered_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_time = Towerops.Time.now()
attrs = %{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "updated-name",
last_discovered_at: new_time
}
{:ok, updated} = Snmp.upsert_neighbor(attrs)
assert updated.id == existing.id
assert updated.remote_system_name == "updated-name"
# Compare with truncation since database stores :utc_datetime (no microseconds)
assert DateTime.compare(updated.last_discovered_at, new_time) in [:eq, :gt]
end
end
describe "delete_stale_neighbors/2" do
test "deletes neighbors older than cutoff", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
last_discovered_at: old_time
})
|> Repo.insert!()
recent_neighbor =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
protocol: "cdp",
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
last_discovered_at: recent_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_neighbors(device.id, cutoff)
assert deleted_count == 1
assert Snmp.get_neighbor(old_neighbor.id) == nil
assert Snmp.get_neighbor(recent_neighbor.id)
end
test "does not delete neighbors from other devices", %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Another Site",
organization_id: organization.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
snmp_device1 =
%Device{}
|> Device.changeset(%{
device_id: device1.id,
sys_name: "router1"
})
|> Repo.insert!()
snmp_device2 =
%Device{}
|> Device.changeset(%{
device_id: device2.id,
sys_name: "router2"
})
|> Repo.insert!()
interface1 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device1.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device2.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
neighbor1 =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device1.id,
interface_id: interface1.id,
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
neighbor2 =
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device2.id,
interface_id: interface2.id,
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_neighbors(device1.id, cutoff)
assert deleted_count == 1
assert Snmp.get_neighbor(neighbor1.id) == nil
assert Snmp.get_neighbor(neighbor2.id)
end
end
describe "get_storage_readings/2" do
test "returns recent readings for a storage entry", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..5 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id)
assert length(readings) == 5
end
test "respects limit option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..10 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id, limit: 3)
assert length(readings) == 3
end
test "respects since option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: past
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
readings = Snmp.get_storage_readings(storage.id, since: since_time)
assert length(readings) == 1
assert hd(readings).usage_percent == 50.0
end
test "returns empty list for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_storage_readings(storage.id) == []
end
end
describe "get_latest_storage_reading/1" do
test "returns most recent reading", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_storage_reading(storage.id)
assert latest.id == new_reading.id
refute latest.id == old_reading.id
end
test "returns nil for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_latest_storage_reading(storage.id) == nil
end
end
describe "get_latest_storage_readings_batch/1" do
test "returns empty map for empty storage list" do
assert Snmp.get_latest_storage_readings_batch([]) == %{}
end
test "returns readings for multiple storage entries", %{snmp_device: snmp_device} do
storage1 =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage2 =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
reading1 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage1.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
reading2 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage2.id,
used_bytes: 2000,
total_bytes: 8000,
usage_percent: 25.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage1.id, storage2.id])
assert map_size(batch) == 2
assert batch[storage1.id].id == reading1.id
assert batch[storage2.id].id == reading2.id
end
test "returns only latest reading per storage", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
_old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage.id])
assert map_size(batch) == 1
assert batch[storage.id].id == new_reading.id
assert batch[storage.id].usage_percent == 50.0
end
test "excludes storage without readings", %{snmp_device: snmp_device} do
storage_with_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage_without_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage_with_reading.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage_with_reading.id, storage_without_reading.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, storage_with_reading.id)
refute Map.has_key?(batch, storage_without_reading.id)
end
end
describe "create_storage_reading/1" do
test "creates a storage reading with valid attributes", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
{:ok, reading} =
Snmp.create_storage_reading(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
assert reading.storage_id == storage.id
assert reading.used_bytes == 5000
assert reading.total_bytes == 10_000
assert reading.usage_percent == 50.0
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_storage_reading(%{used_bytes: 1000})
assert changeset.errors[:storage_id]
end
end
describe "list_arp_entries/1" do
test "returns all ARP entries for a device", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).id == arp.id
assert Ecto.assoc_loaded?(hd(entries).interface)
end
test "returns empty list for device with no ARP entries", %{device: device} do
assert Snmp.list_arp_entries(device.id) == []
end
test "orders ARP entries by IP address", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp2 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "aa:bb:cc:dd:ee:02",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
arp1 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:01",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
assert Enum.at(entries, 0).id == arp1.id
assert Enum.at(entries, 1).id == arp2.id
end
end
describe "get_arp_entry/1" do
test "returns ARP entry by id", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
found = Snmp.get_arp_entry(arp.id)
assert found.id == arp.id
assert found.ip_address == "192.168.1.100"
end
test "returns nil for non-existent id" do
assert Snmp.get_arp_entry(Ecto.UUID.generate()) == nil
end
end
describe "upsert_arp_entry/1" do
test "creates new ARP entry when none exists", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.utc_now()
}
{:ok, arp} = Snmp.upsert_arp_entry(attrs)
assert arp.ip_address == "192.168.1.100"
assert arp.mac_address == "aa:bb:cc:dd:ee:ff"
end
test "updates existing ARP entry when match found", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
existing =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
if_index: 1,
last_seen_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_time = Towerops.Time.now()
attrs = %{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "static",
if_index: 1,
last_seen_at: new_time
}
{:ok, updated} = Snmp.upsert_arp_entry(attrs)
assert updated.id == existing.id
assert updated.entry_type == "static"
end
end
describe "delete_stale_arp_entries/2" do
test "deletes ARP entries older than cutoff", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
recent_time = DateTime.add(DateTime.utc_now(), -60, :second)
old_arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
recent_arp =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
interface_id: interface.id,
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
last_seen_at: recent_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(old_arp.id) == nil
assert Snmp.get_arp_entry(recent_arp.id)
end
test "does not delete ARP entries from other devices", %{organization: organization} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Another Site",
organization_id: organization.id
})
{:ok, device1} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id
})
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
arp1 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device1.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: old_time
})
|> Repo.insert!()
arp2 =
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device2.id,
ip_address: "192.168.1.100",
mac_address: "11:22:33:44:55:66",
last_seen_at: old_time
})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -3600, :second)
{deleted_count, _} = Snmp.delete_stale_arp_entries(device1.id, cutoff)
assert deleted_count == 1
assert Snmp.get_arp_entry(arp1.id) == nil
assert Snmp.get_arp_entry(arp2.id)
end
end
describe "upsert_arp_entries/3" do
test "creates multiple ARP entries with interface mapping", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
},
%{
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
if_index: 1,
entry_type: "static",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert success == 2
assert errors == 0
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 2
ips = entries |> Enum.map(& &1.ip_address) |> Enum.sort()
assert ips == ["192.168.1.100", "192.168.1.200"]
# Both should have the interface linked
assert Enum.all?(entries, &(&1.interface_id == interface.id))
end
test "handles entries without matching interface", %{device: device} do
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 999,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [])
assert success == 1
assert errors == 0
entries = Snmp.list_arp_entries(device.id)
assert length(entries) == 1
assert hd(entries).interface_id == nil
assert hd(entries).if_index == 999
end
end
describe "upsert_arp_entries/3 error tracking" do
test "returns success and error counts", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
arp_data = [
%{
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
},
%{
ip_address: "192.168.1.200",
mac_address: "11:22:33:44:55:66",
if_index: 1,
entry_type: "static",
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert success == 2
assert errors == 0
end
end
describe "upsert_mac_addresses/3 error tracking" do
test "returns success and error counts", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
mac_data = [
%{
mac_address: "aa:bb:cc:dd:ee:ff",
port_index: 1,
vlan_id: 1,
last_seen_at: DateTime.utc_now()
},
%{
mac_address: "11:22:33:44:55:66",
port_index: 1,
vlan_id: 1,
last_seen_at: DateTime.utc_now()
}
]
{success, errors} = Snmp.upsert_mac_addresses(device.id, mac_data, [interface])
assert success == 2
assert errors == 0
end
end
describe "list_discovered_devices_for_organization/1" do
test "merges data from neighbors, ARP, and MAC tables", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create a neighbor entry
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-switch",
remote_platform: "Cisco IOS Software",
remote_capabilities: ["bridge", "router"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create an ARP entry with same MAC
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
if_index: 1,
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should merge data from both sources
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
assert entry.hostname == "discovered-switch"
assert "192.168.1.100" in entry.ip_addresses
assert "aa:bb:cc:dd:ee:ff" in entry.mac_addresses
assert entry.source_count == 2
assert :lldp in entry.protocols_used
assert :arp in entry.protocols_used
end
test "filters out existing devices by IP address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with same IP as existing device
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-router",
remote_address: device.ip_address,
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "filters out existing devices by MAC address", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_phys_address: "aa:bb:cc:dd:ee:ff"
})
|> Repo.insert!()
# Create neighbor with same MAC as existing interface
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "discovered-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
# Should be filtered out
assert discovered == []
end
test "infers device type from LLDP capabilities", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with bridge capability
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "switch-device",
remote_capabilities: ["bridge"],
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :switch
end
test "infers device type from platform description", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with Cisco IOS description
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "router-device",
remote_platform: "Cisco IOS Software, Version 15.0",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
assert hd(discovered).device_type == :router
end
test "prefers MAC address as primary identifier", %{
organization: organization,
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create neighbor with both MAC and IP
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "aa:bb:cc:dd:ee:ff",
remote_port_id: "Gi1/0/1",
remote_system_name: "test-device",
remote_address: "192.168.1.100",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 1
entry = hd(discovered)
# Should prefer MAC as identifier
assert entry.identifier.type == :mac
assert entry.identifier.value == "aa:bb:cc:dd:ee:ff"
end
test "returns empty list when no discovery data exists", %{organization: organization} do
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert discovered == []
end
test "sorts by source count and last seen", %{organization: organization, device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Create one neighbor with single source (older)
old_time = DateTime.add(DateTime.utc_now(), -3600, :second)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "11:11:11:11:11:11",
remote_port_id: "Gi1/0/1",
remote_system_name: "old-device",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
# Create another neighbor with single source (newer)
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "22:22:22:22:22:22",
remote_port_id: "Gi1/0/2",
remote_system_name: "new-device",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
})
|> Repo.insert!()
# Create a device with multiple sources
%Neighbor{}
|> Neighbor.changeset(%{
device_id: device.id,
snmp_device_id: snmp_device.id,
interface_id: interface.id,
remote_chassis_id: "33:33:33:33:33:33",
remote_port_id: "Gi1/0/3",
remote_system_name: "multi-source",
protocol: "lldp",
last_discovered_at: old_time
})
|> Repo.insert!()
%ArpEntry{}
|> ArpEntry.changeset(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "33:33:33:33:33:33",
if_index: 1,
entry_type: "dynamic",
last_seen_at: old_time
})
|> Repo.insert!()
discovered = Snmp.list_discovered_devices_for_organization(organization.id)
assert length(discovered) == 3
# First should be multi-source (source_count = 2)
assert hd(discovered).hostname == "multi-source"
assert hd(discovered).source_count == 2
# Next two should be sorted by last_seen (newer first)
assert Enum.at(discovered, 1).hostname == "new-device"
assert Enum.at(discovered, 2).hostname == "old-device"
end
end
describe "concurrent upsert operations (race condition tests)" do
test "concurrent neighbor upserts don't create duplicates", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Spawn 10 concurrent workers all upserting the same neighbor
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "00:11:22:33:44:55",
protocol: "lldp",
remote_system_name: "neighbor-#{i}",
last_discovered_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 neighbor, not 10
neighbors =
Repo.all(
from n in Neighbor,
where: n.interface_id == ^interface.id and n.remote_chassis_id == "00:11:22:33:44:55"
)
assert length(neighbors) == 1
# The final name should be one of the concurrent writes (non-deterministic but valid)
neighbor = hd(neighbors)
assert String.starts_with?(neighbor.remote_system_name, "neighbor-")
end
test "concurrent ARP entry upserts don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same ARP entry
tasks =
for _i <- 1..10 do
Task.async(fn ->
Snmp.upsert_arp_entry(%{
device_id: device.id,
ip_address: "192.168.1.100",
mac_address: "aa:bb:cc:dd:ee:ff",
entry_type: "dynamic",
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 ARP entry, not 10
arp_entries =
Repo.all(
from a in ArpEntry,
where:
a.device_id == ^device.id and a.ip_address == "192.168.1.100" and
a.mac_address == "aa:bb:cc:dd:ee:ff"
)
assert length(arp_entries) == 1
end
test "concurrent MAC address upserts don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same MAC address
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_mac_address(%{
device_id: device.id,
mac_address: "11:22:33:44:55:66",
vlan_id: 100,
port_index: i,
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 MAC address, not 10
mac_addresses =
Repo.all(
from m in MacAddress,
where: m.device_id == ^device.id and m.mac_address == "11:22:33:44:55:66" and m.vlan_id == 100
)
assert length(mac_addresses) == 1
# The final port_index should be one of the concurrent writes
mac = hd(mac_addresses)
assert mac.port_index in 1..10
end
test "concurrent MAC address upserts with nil vlan_id don't create duplicates", %{device: device} do
# Spawn 10 concurrent workers all upserting the same MAC address with nil vlan_id
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_mac_address(%{
device_id: device.id,
mac_address: "aa:bb:cc:dd:ee:ff",
vlan_id: nil,
port_index: i,
last_seen_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 MAC address, not 10
mac_addresses =
Repo.all(
from m in MacAddress,
where: m.device_id == ^device.id and m.mac_address == "aa:bb:cc:dd:ee:ff" and is_nil(m.vlan_id)
)
assert length(mac_addresses) == 1
end
test "concurrent neighbor upserts with nil chassis_id handled correctly", %{
device: device,
snmp_device: snmp_device
} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
# Spawn 10 concurrent workers all upserting neighbors with nil chassis_id
tasks =
for i <- 1..10 do
Task.async(fn ->
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: nil,
protocol: "cdp",
remote_system_name: "neighbor-#{i}",
last_discovered_at: DateTime.utc_now()
})
end)
end
# Wait for all tasks to complete
results = Task.await_many(tasks, 5_000)
# All should succeed
assert Enum.all?(results, fn
{:ok, _} -> true
_ -> false
end)
# Should have exactly 1 neighbor with nil chassis_id
neighbors =
Repo.all(
from n in Neighbor,
where: n.interface_id == ^interface.id and is_nil(n.remote_chassis_id) and n.protocol == "cdp"
)
assert length(neighbors) == 1
end
end
describe "delete_stale_and_upsert_neighbors/3" do
test "atomically deletes stale and upserts new neighbors", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
cutoff = DateTime.add(DateTime.utc_now(), -5, :minute)
# Create a stale neighbor (old timestamp)
{:ok, _stale} =
Snmp.upsert_neighbor(%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "stale-chassis",
remote_port_id: "ge0/0/1",
remote_system_name: "stale-switch",
protocol: "lldp",
last_discovered_at: DateTime.add(DateTime.utc_now(), -10, :minute)
})
# New neighbors to upsert
new_neighbors = [
%{
device_id: device.id,
interface_id: interface.id,
remote_chassis_id: "new-chassis",
remote_port_id: "ge0/0/2",
remote_system_name: "new-switch",
protocol: "lldp",
last_discovered_at: DateTime.utc_now()
}
]
assert {:ok, _} = Snmp.delete_stale_and_upsert_neighbors(device.id, new_neighbors, cutoff)
remaining = Repo.all(from n in Neighbor, where: n.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).remote_chassis_id == "new-chassis"
end
end
describe "delete_stale_and_upsert_arp_entries/4" do
test "atomically deletes stale and upserts new ARP entries", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
now = Towerops.Time.now()
cutoff = DateTime.add(now, -5, :minute)
# Create a stale ARP entry
Repo.insert!(%ArpEntry{
device_id: device.id,
interface_id: interface.id,
ip_address: "10.0.0.99",
mac_address: "aa:bb:cc:dd:ee:ff",
last_seen_at: DateTime.add(now, -10, :minute)
})
# New entries to upsert
new_entries = [
%{
ip_address: "10.0.0.100",
mac_address: "11:22:33:44:55:66",
if_index: interface.if_index,
last_seen_at: now
}
]
assert {:ok, _} =
Snmp.delete_stale_and_upsert_arp_entries(
device.id,
new_entries,
[interface],
cutoff
)
remaining = Repo.all(from a in ArpEntry, where: a.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).ip_address == "10.0.0.100"
end
end
describe "delete_stale_and_upsert_mac_addresses/4" do
test "atomically deletes stale and upserts new MAC addresses", %{device: device, snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
now = Towerops.Time.now()
cutoff = DateTime.add(now, -5, :minute)
# Create a stale MAC entry
Repo.insert!(%MacAddress{
device_id: device.id,
interface_id: interface.id,
mac_address: "aa:bb:cc:dd:ee:ff",
vlan_id: 1,
last_seen_at: DateTime.add(now, -10, :minute)
})
# New entries to upsert
new_entries = [
%{
mac_address: "11:22:33:44:55:66",
port_index: interface.if_index,
vlan_id: 1,
last_seen_at: now
}
]
assert {:ok, _} =
Snmp.delete_stale_and_upsert_mac_addresses(
device.id,
new_entries,
[interface],
cutoff
)
remaining = Repo.all(from m in MacAddress, where: m.device_id == ^device.id)
assert length(remaining) == 1
assert hd(remaining).mac_address == "11:22:33:44:55:66"
end
end
describe "create_checks_from_discovery/2" do
test "creates checks for sensors", %{device: device, snmp_device: snmp_device} do
# Create a sensor to discover
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_descr: "CPU Temperature",
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.3.1.3.1",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
# Run check creation
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.sensors == 1
# Verify check was created (auto ping check + sensor check)
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = Enum.find(checks, &(&1.check_type == "snmp_sensor"))
assert check
assert check.name == "CPU Temperature"
assert check.check_type == "snmp_sensor"
assert check.source_type == "auto_discovery"
assert check.source_id == sensor.id
assert check.device_id == device.id
assert check.enabled == true
assert check.interval_seconds == 60
# Verify config contains sensor metadata
assert check.config["sensor_type"] == "temperature"
assert check.config["sensor_oid"] == ".1.3.6.1.4.1.9.9.13.1.3.1.3.1"
end
test "creates checks for interfaces", %{device: device, snmp_device: snmp_device} do
# Create an interface
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_name: "Ethernet0",
if_type: 6,
if_speed: 1_000_000_000,
if_oper_status: "up"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.interfaces == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = Enum.find(checks, &(&1.check_type == "snmp_interface"))
assert check
assert check.name == "Ethernet0 Status"
assert check.check_type == "snmp_interface"
assert check.source_type == "auto_discovery"
assert check.source_id == interface.id
assert check.config["if_index"] == 1
assert check.config["if_descr"] == "eth0"
end
test "creates checks for processors", %{device: device, snmp_device: snmp_device} do
# Create a processor
processor =
%Processor{}
|> Processor.changeset(%{
snmp_device_id: snmp_device.id,
processor_index: "1",
description: "CPU 1",
processor_type: "hr_processor"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.processors == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = Enum.find(checks, &(&1.check_type == "snmp_processor"))
assert check
assert check.name == "CPU 1"
assert check.check_type == "snmp_processor"
assert check.source_id == processor.id
end
test "creates checks for storage", %{device: device, snmp_device: snmp_device} do
# Create storage
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
description: "/ (root)",
storage_type: "fixed_disk",
total_bytes: 100_000_000,
used_bytes: 50_000_000
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.storage == 1
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
check = Enum.find(checks, &(&1.check_type == "snmp_storage"))
assert check
assert check.name == "/ (root) Usage"
assert check.check_type == "snmp_storage"
assert check.source_id == storage.id
end
test "creates multiple checks for different types", %{device: device, snmp_device: snmp_device} do
# Create multiple items
_sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_descr: "Temperature 1",
sensor_index: "1",
sensor_oid: ".1.2.3.4",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
_interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0",
if_oper_status: "up"
})
|> Repo.insert!()
assert {:ok, counts} = Snmp.create_checks_from_discovery(device, snmp_device)
assert counts.sensors == 1
assert counts.interfaces == 1
assert counts.processors == 0
assert counts.storage == 0
checks = Towerops.Monitoring.list_checks(device.organization_id, device_id: device.id)
snmp_checks = Enum.reject(checks, &(&1.check_type == "ping"))
assert length(snmp_checks) == 2
check_types = snmp_checks |> Enum.map(& &1.check_type) |> Enum.sort()
assert check_types == ["snmp_interface", "snmp_sensor"]
end
test "schedules checks for execution", %{device: device, snmp_device: snmp_device} do
_sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_descr: "Test Sensor",
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: ".1.2.3.4",
sensor_unit: "°C",
sensor_divisor: 1
})
|> Repo.insert!()
# Clear pre-existing jobs (e.g., from auto ping check creation)
Repo.delete_all(from(j in Oban.Job, where: j.worker == "Towerops.Workers.CheckExecutorWorker"))
assert {:ok, _counts} = Snmp.create_checks_from_discovery(device, snmp_device)
# Verify Oban job was scheduled for the discovered sensor check
jobs = Repo.all(from(j in Oban.Job, where: j.worker == "Towerops.Workers.CheckExecutorWorker"))
assert length(jobs) == 1
job = hd(jobs)
assert job.worker == "Towerops.Workers.CheckExecutorWorker"
assert job.queue == "check_executors"
end
end
describe "set_manual_capacity/2" do
test "sets manual capacity on an interface", %{snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{snmp_device_id: snmp_device.id, if_index: 1, if_name: "eth0"})
|> Repo.insert!()
{:ok, updated} = Snmp.set_manual_capacity(interface.id, 300_000_000)
assert updated.configured_capacity_bps == 300_000_000
assert updated.capacity_source == "manual"
end
test "returns error for non-existent interface" do
assert {:error, :not_found} = Snmp.set_manual_capacity(Ecto.UUID.generate(), 100_000)
end
end
describe "clear_manual_capacity/1" do
test "clears capacity fields on an interface", %{snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
configured_capacity_bps: 300_000_000,
capacity_source: "manual"
})
|> Repo.insert!()
{:ok, updated} = Snmp.clear_manual_capacity(interface.id)
assert is_nil(updated.configured_capacity_bps)
assert is_nil(updated.capacity_source)
end
end
describe "create_wireless_client_readings_batch/1" do
setup %{device: device, organization: organization} do
# Create wireless client
wireless_client =
%WirelessClient{}
|> WirelessClient.changeset(%{
device_id: device.id,
organization_id: organization.id,
mac_address: "AA:BB:CC:DD:EE:FF",
ip_address: "10.0.1.100",
signal_strength: -65,
snr: 25,
distance: 1000,
tx_rate: 100_000,
rx_rate: 50_000,
uptime_seconds: 3600,
last_seen_at: Towerops.Time.now()
})
|> Repo.insert!()
%{wireless_client: wireless_client}
end
test "inserts batch of wireless client readings", %{
snmp_device: snmp_device,
organization: organization,
wireless_client: wireless_client
} do
now = Towerops.Time.now()
entries = [
%{
device_id: snmp_device.id,
wireless_client_id: wireless_client.id,
organization_id: organization.id,
mac_address: wireless_client.mac_address,
ip_address: wireless_client.ip_address,
signal_strength: -65,
snr: 25,
distance: 1000,
tx_rate: 100_000,
rx_rate: 50_000,
uptime_seconds: 3600,
checked_at: now
},
%{
device_id: snmp_device.id,
wireless_client_id: wireless_client.id,
organization_id: organization.id,
mac_address: wireless_client.mac_address,
ip_address: wireless_client.ip_address,
signal_strength: -70,
snr: 20,
distance: 1200,
tx_rate: 80_000,
rx_rate: 40_000,
uptime_seconds: 3700,
checked_at: DateTime.add(now, -60, :second)
}
]
{count, nil} = Snmp.create_wireless_client_readings_batch(entries)
assert count == 2
# Verify readings were inserted
readings =
Repo.all(
from r in WirelessClientReading,
where: r.wireless_client_id == ^wireless_client.id,
order_by: [desc: r.checked_at]
)
assert length(readings) == 2
[reading1, reading2] = readings
# Verify first reading (most recent)
assert reading1.device_id == snmp_device.id
assert reading1.wireless_client_id == wireless_client.id
assert reading1.organization_id == organization.id
assert reading1.mac_address == wireless_client.mac_address
assert reading1.signal_strength == -65
assert reading1.snr == 25
assert reading1.checked_at == now
# Verify second reading (older)
assert reading2.signal_strength == -70
assert reading2.snr == 20
assert DateTime.diff(reading1.checked_at, reading2.checked_at) == 60
end
test "handles empty list", _context do
{count, nil} = Snmp.create_wireless_client_readings_batch([])
assert count == 0
end
test "generates UUIDs and timestamps automatically", %{
snmp_device: snmp_device,
organization: organization,
wireless_client: wireless_client
} do
now = Towerops.Time.now()
entries = [
%{
device_id: snmp_device.id,
wireless_client_id: wireless_client.id,
organization_id: organization.id,
mac_address: wireless_client.mac_address,
signal_strength: -60,
checked_at: now
}
]
{count, nil} = Snmp.create_wireless_client_readings_batch(entries)
assert count == 1
reading = Repo.one!(WirelessClientReading)
assert reading.id
assert reading.inserted_at
end
test "handles nil metric values", %{
snmp_device: snmp_device,
organization: organization,
wireless_client: wireless_client
} do
now = Towerops.Time.now()
entries = [
%{
device_id: snmp_device.id,
wireless_client_id: wireless_client.id,
organization_id: organization.id,
mac_address: wireless_client.mac_address,
signal_strength: nil,
snr: nil,
distance: nil,
tx_rate: nil,
rx_rate: nil,
uptime_seconds: nil,
checked_at: now
}
]
{count, nil} = Snmp.create_wireless_client_readings_batch(entries)
assert count == 1
reading = Repo.one!(WirelessClientReading)
assert is_nil(reading.signal_strength)
assert is_nil(reading.snr)
assert is_nil(reading.distance)
end
end
end