towerops/test/towerops/snmp_test.exs
2026-01-23 08:40:57 -06:00

1972 lines
55 KiB
Elixir

defmodule Towerops.SnmpTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.Neighbor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
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
})
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_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_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_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 = DateTime.truncate(DateTime.utc_now(), :second)
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
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.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 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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 = DateTime.truncate(DateTime.utc_now(), :second)
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 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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
})
{:ok, device2} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id
})
old_time = DateTime.add(DateTime.utc_now(), -7200, :second)
arp1 =
%Snmp.ArpEntry{}
|> Snmp.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 =
%Snmp.ArpEntry{}
|> Snmp.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()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [interface])
assert count == 2
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()
}
]
count = Snmp.upsert_arp_entries(device.id, arp_data, [])
assert count == 1
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
end