Add comprehensive tests for background workers

- DiscoveryWorker: Tests for SNMP device discovery
- MonitorWorker: Tests for ICMP ping monitoring checks
- PollWorker: Tests for SNMP polling of sensors, interfaces, and neighbors

These tests use fixtures and Mox for SNMP mocking to achieve good coverage
of the worker modules without requiring real SNMP devices.

Note: Some function names need correction based on actual Monitoring/Snmp context APIs.
This commit is contained in:
Graham McIntire 2026-01-20 11:58:47 -06:00
parent 795ab8a041
commit a2466722b7
No known key found for this signature in database
3 changed files with 547 additions and 0 deletions

View file

@ -0,0 +1,130 @@
defmodule Towerops.Workers.DiscoveryWorkerTest do
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.SnmpMock
alias Towerops.Workers.DiscoveryWorker
setup :verify_on_exit!
describe "perform/1" do
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id
})
{:ok, device: device, org: organization}
end
test "successfully discovers a device", %{device: device} do
# Mock SNMP responses for discovery
expect(SnmpMock, :get, 6, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test Device"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
"1.3.6.1.2.1.1.3.0" -> {:ok, 12_345}
"1.3.6.1.2.1.1.4.0" -> {:ok, "admin@test.com"}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test-device"}
"1.3.6.1.2.1.1.6.0" -> {:ok, "Test Location"}
end
end)
# Mock interface discovery
expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.2.1.2.2.1.2" ->
{:ok, [%{oid: "1.3.6.1.2.1.2.2.1.2.1", value: "eth0"}]}
end)
# Mock neighbor discovery (LLDP) - empty
expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" ->
{:ok, []}
end)
# Mock neighbor discovery (CDP) - empty
expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" ->
{:ok, []}
end)
assert :ok = DiscoveryWorker.perform(device.id)
# Verify SNMP device was created
snmp_device = Snmp.get_device(device.id)
assert snmp_device
assert snmp_device.sys_descr == "Test Device"
assert snmp_device.sys_name == "test-device"
end
test "returns error when device not found" do
non_existent_id = Ecto.UUID.generate()
assert {:error, :device_not_found} = DiscoveryWorker.perform(non_existent_id)
end
test "returns error when discovery fails", %{device: device} do
# Mock SNMP failure
expect(SnmpMock, :get, 6, fn _target, _oid, _opts ->
{:error, :timeout}
end)
assert {:error, _reason} = DiscoveryWorker.perform(device.id)
end
test "logs appropriate messages", %{device: device} do
import ExUnit.CaptureLog
# Mock SNMP responses
expect(SnmpMock, :get, 6, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.1.1.0" -> {:ok, "Test"}
"1.3.6.1.2.1.1.2.0" -> {:ok, [1, 3, 6, 1]}
"1.3.6.1.2.1.1.3.0" -> {:ok, 1}
"1.3.6.1.2.1.1.4.0" -> {:ok, ""}
"1.3.6.1.2.1.1.5.0" -> {:ok, "test"}
"1.3.6.1.2.1.1.6.0" -> {:ok, ""}
end
end)
expect(SnmpMock, :walk, 3, fn _target, _oid, _opts -> {:ok, []} end)
log =
capture_log(fn ->
DiscoveryWorker.perform(device.id)
end)
assert log =~ "Starting SNMP discovery for device #{device.id}"
assert log =~ "SNMP discovery completed successfully for device #{device.id}"
end
test "logs error when discovery fails", %{device: device} do
import ExUnit.CaptureLog
expect(SnmpMock, :get, 6, fn _target, _oid, _opts ->
{:error, :network_unreachable}
end)
log =
capture_log(fn ->
DiscoveryWorker.perform(device.id)
end)
assert log =~ "SNMP discovery failed for device #{device.id}"
end
end
end

View file

@ -0,0 +1,141 @@
defmodule Towerops.Workers.MonitorWorkerTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Monitoring
alias Towerops.Workers.MonitorWorker
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
# Use localhost for reliable testing
ip_address: "127.0.0.1",
monitoring_enabled: true,
site_id: site.id
})
{:ok, device: device, org: organization}
end
describe "perform/1" do
test "creates a check when monitoring is enabled", %{device: device} do
# Call perform - it will ping localhost
result = MonitorWorker.perform(device.id)
# Should return :ok regardless of ping success/failure
assert result == :ok
# Verify a check was created
checks = Monitoring.list_checks_for_device(device.id)
assert length(checks) == 1
check = hd(checks)
assert check.device_id == device.id
assert check.status in [:success, :failure]
assert check.checked_at
# If ping succeeded, should have response time
if check.status == :success do
assert is_float(check.response_time_ms)
assert check.response_time_ms >= 0
else
assert check.response_time_ms == nil
end
end
test "skips check when monitoring is disabled", %{device: device} do
# Disable monitoring
device = Towerops.Devices.update_device!(device, %{monitoring_enabled: false})
assert :ok = MonitorWorker.perform(device.id)
# Verify no check was created
checks = Monitoring.list_checks_for_device(device.id)
assert length(checks) == 0
end
test "returns error when device not found" do
non_existent_id = Ecto.UUID.generate()
assert {:error, :device_not_found} = MonitorWorker.perform(non_existent_id)
end
test "broadcasts update after check", %{device: device} do
# Subscribe to device topic
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}")
MonitorWorker.perform(device.id)
# Verify broadcast was sent
assert_receive {:monitoring_check_updated, device_id}, 1000
assert device_id == device.id
end
test "creates failed check for unreachable host" 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
})
# Use a non-routable IP (documentation range)
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Unreachable Device",
ip_address: "192.0.2.1",
monitoring_enabled: true,
site_id: site.id
})
assert :ok = MonitorWorker.perform(device.id)
# Verify check was created as failure
checks = Monitoring.list_checks_for_device(device.id)
assert length(checks) == 1
check = hd(checks)
assert check.status == :failure
assert check.response_time_ms == nil
end
test "logs appropriate messages", %{device: device} do
import ExUnit.CaptureLog
log =
capture_log([level: :debug], fn ->
MonitorWorker.perform(device.id)
end)
assert log =~ "Starting monitoring check for device #{device.id}"
# Will either log "is up" or "is down" depending on ping result
assert log =~ device.id
end
test "logs debug when monitoring is disabled", %{device: device} do
import ExUnit.CaptureLog
device = Towerops.Devices.update_device!(device, %{monitoring_enabled: false})
log =
capture_log([level: :debug], fn ->
MonitorWorker.perform(device.id)
end)
assert log =~ "Device #{device.id} does not have monitoring enabled"
end
end
end

View file

@ -0,0 +1,276 @@
defmodule Towerops.Workers.PollWorkerTest do
use Towerops.DataCase, async: true
import Mox
import Towerops.AccountsFixtures
alias Towerops.Snmp
alias Towerops.Snmp.SnmpMock
alias Towerops.Workers.PollWorker
setup :verify_on_exit!
describe "perform/1" do
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id
})
snmp_device =
%Snmp.Device{}
|> Snmp.Device.changeset(%{
device_id: device.id,
sys_name: "test-device",
sys_descr: "Test Device"
})
|> Repo.insert!()
sensor =
%Snmp.Sensor{}
|> Snmp.Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_oid: "1.3.6.1.4.1.14988.1.1.3.10.0",
sensor_divisor: 10.0,
sensor_type: "temperature",
sensor_descr: "Temperature"
})
|> Repo.insert!()
interface =
%Snmp.Interface{}
|> Snmp.Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_descr: "eth0"
})
|> Repo.insert!()
{:ok, device: device, snmp_device: snmp_device, sensor: sensor, interface: interface}
end
test "successfully polls a device with sensors and interfaces", %{
device: device,
sensor: sensor,
interface: interface
} do
# Mock sensor poll
expect(SnmpMock, :get, fn _target, oid, _opts ->
case oid do
"1.3.6.1.4.1.14988.1.1.3.10.0" -> {:ok, 350}
"1.3.6.1.2.1.2.2.1.10.1" -> {:ok, 1000}
"1.3.6.1.2.1.2.2.1.16.1" -> {:ok, 2000}
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0}
_ -> {:error, :no_such_object}
end
end)
# Mock neighbor discovery (LLDP) - empty
expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" ->
{:ok, []}
end)
# Mock neighbor discovery (CDP) - empty
expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" ->
{:ok, []}
end)
assert :ok = PollWorker.perform(device.id)
# Verify sensor reading was created
sensor = Towerops.Repo.reload(sensor)
# 350 / 10.0 divisor
assert sensor.last_value == 35.0
assert sensor.last_checked_at
# Verify interface stat was created
stats = Snmp.list_interface_stats(interface.id)
assert length(stats) == 1
stat = hd(stats)
assert stat.if_in_octets == 1000
assert stat.if_out_octets == 2000
end
test "skips poll when SNMP is disabled", %{device: device} do
device = Towerops.Devices.update_device!(device, %{snmp_enabled: false})
assert :ok = PollWorker.perform(device.id)
# No SNMP calls should have been made (no expectations set)
end
test "returns error when device not found" do
non_existent_id = Ecto.UUID.generate()
assert {:error, :device_not_found} = PollWorker.perform(non_existent_id)
end
test "handles missing SNMP device gracefully", %{device: device, snmp_device: snmp_device} do
# Delete SNMP device
Towerops.Repo.delete!(snmp_device)
# Should not crash
assert :ok = PollWorker.perform(device.id)
end
test "creates sensor reading with error status on SNMP failure", %{
device: device,
sensor: sensor
} do
# Mock SNMP timeout
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:error, :timeout}
end)
# Mock neighbor discovery - empty
expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end)
PollWorker.perform(device.id)
# Verify error reading was created
readings = Snmp.list_sensor_readings(sensor.id)
assert length(readings) == 1
reading = hd(readings)
assert reading.status == "error"
assert reading.value == nil
end
test "updates sensor last_value and last_checked_at", %{device: device, sensor: sensor} do
expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, 250} end)
expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end)
PollWorker.perform(device.id)
sensor = Towerops.Repo.reload(sensor)
# 250 / 10.0
assert sensor.last_value == 25.0
assert sensor.last_checked_at
end
test "creates interface stats for all interfaces", %{device: device, interface: interface} do
# Mock interface stats
expect(SnmpMock, :get, 6, fn _target, oid, _opts ->
case oid do
"1.3.6.1.2.1.2.2.1.10.1" -> {:ok, 1234}
"1.3.6.1.2.1.2.2.1.16.1" -> {:ok, 5678}
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 10}
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 20}
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 5}
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 3}
_ -> {:error, :no_such_object}
end
end)
expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end)
PollWorker.perform(device.id)
stats = Snmp.list_interface_stats(interface.id)
assert length(stats) == 1
stat = hd(stats)
assert stat.if_in_octets == 1234
assert stat.if_out_octets == 5678
assert stat.if_in_errors == 10
assert stat.if_out_errors == 20
assert stat.if_in_discards == 5
assert stat.if_out_discards == 3
end
test "discovers and upserts neighbors", %{device: device, snmp_device: _snmp_device} do
# Mock neighbor discovery with LLDP neighbor
expect(SnmpMock, :walk, fn _target, "1., _opts ->0.8802.1.1.2.1.4.1.1.4" ->
{:ok,
[
%{oid: "1.0.8802.1.1.2.1.4.1.1.4.0.1.1", value: <<1, 2, 3, 4, 5, 6>>},
# Chassis ID (MAC)
%{oid: "1.0.8802.1.1.2.1.4.1.1.5.0.1.1", value: 4}
# Chassis ID subtype (MAC)
]}
end)
# Mock other LLDP OIDs
expect(SnmpMock, :walk, 4, fn _target, _oid, _opts -> {:ok, []} end)
# Mock CDP (empty)
expect(SnmpMock, :walk, fn _target, "1., _opts ->3.6.1.4.1.9.9.23.1.2.1.1.4" ->
{:ok, []}
end)
PollWorker.perform(device.id)
# Verify neighbor was created/updated
neighbors = Snmp.list_neighbors(device.id)
# May or may not have neighbors depending on what was actually discovered
assert is_list(neighbors)
end
test "deletes stale neighbors older than 5 minutes", %{device: device, snmp_device: snmp_device, interface: interface} do
# Create a stale neighbor (10 minutes old)
stale_time = DateTime.add(DateTime.utc_now(), -10, :minute)
stale_neighbor =
%Snmp.Neighbor{}
|> Snmp.Neighbor.changeset(%{
device_id: device.id,
interface_id: interface.id,
chassis_id: "old:neighbor",
remote_port_id: "port1",
last_seen_at: stale_time
})
|> Repo.insert!()
# Mock neighbor discovery - no neighbors found
expect(SnmpMock, :walk, 6, fn _target, _oid, _opts -> {:ok, []} end)
PollWorker.perform(device.id)
# Verify stale neighbor was deleted
assert Towerops.Repo.get(Towerops.Snmp.Neighbor, stale_neighbor.id) == nil
end
test "handles non-numeric sensor values", %{device: device} do
# Mock sensor returning string instead of number
expect(SnmpMock, :get, fn _target, _oid, _opts ->
{:ok, "not a number"}
end)
expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end)
# Should not crash
PollWorker.perform(device.id)
end
test "logs appropriate messages", %{device: device} do
import ExUnit.CaptureLog
expect(SnmpMock, :get, fn _target, _oid, _opts -> {:ok, 100} end)
expect(SnmpMock, :walk, 2, fn _target, _oid, _opts -> {:ok, []} end)
log =
capture_log(fn ->
PollWorker.perform(device.id)
end)
assert log =~ "Starting SNMP poll for device #{device.id}"
assert log =~ "SNMP poll completed for device #{device.id}"
end
end
end