- Remove unused Ecto.Query import from device_poller_worker_test - Update route paths from /orgs/:slug to /dashboard after navigation refactor - Fix HostParser tests to match actual hostname resolution behavior - Update UserAuth redirect assertions to expect /dashboard instead of /devices - Fix DashboardLiveTest missing organization context - Fix OrgLive.NewTest to verify organization creation correctly All 4850 tests now passing with zero failures and zero warnings.
358 lines
11 KiB
Elixir
358 lines
11 KiB
Elixir
defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|
use Towerops.DataCase, async: false
|
|
|
|
import Mox
|
|
import Towerops.AccountsFixtures
|
|
|
|
alias Towerops.Devices
|
|
alias Towerops.Organizations
|
|
alias Towerops.Repo
|
|
alias Towerops.Sites
|
|
alias Towerops.Snmp.Device
|
|
alias Towerops.Snmp.Interface
|
|
alias Towerops.Snmp.Sensor
|
|
alias Towerops.Snmp.SnmpMock
|
|
alias Towerops.Snmp.StateSensor
|
|
alias Towerops.Workers.DevicePollerWorker
|
|
alias Towerops.Workers.PollingOffset
|
|
|
|
setup :verify_on_exit!
|
|
|
|
setup do
|
|
# Configure Mock SNMP Adapter
|
|
old_adapter = Application.get_env(:towerops, :snmp_adapter)
|
|
Application.put_env(:towerops, :snmp_adapter, SnmpMock)
|
|
|
|
# Disable Phoenix SNMP check for tests
|
|
old_disable = Application.get_env(:towerops, :disable_phoenix_snmp)
|
|
Application.put_env(:towerops, :disable_phoenix_snmp, false)
|
|
|
|
on_exit(fn ->
|
|
if old_adapter do
|
|
Application.put_env(:towerops, :snmp_adapter, old_adapter)
|
|
else
|
|
Application.delete_env(:towerops, :snmp_adapter)
|
|
end
|
|
|
|
if old_disable == nil do
|
|
Application.delete_env(:towerops, :disable_phoenix_snmp)
|
|
else
|
|
Application.put_env(:towerops, :disable_phoenix_snmp, old_disable)
|
|
end
|
|
end)
|
|
|
|
user = user_fixture()
|
|
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
|
|
|
|
{:ok, site} =
|
|
Sites.create_site(%{
|
|
name: "Test Site",
|
|
organization_id: organization.id
|
|
})
|
|
|
|
%{organization: organization, site: site, user: user}
|
|
end
|
|
|
|
describe "perform/1" do
|
|
test "returns :ok when device does not exist" do
|
|
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => Ecto.UUID.generate()}})
|
|
end
|
|
|
|
test "skips polling when snmp_enabled is false", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Router No SNMP",
|
|
ip_address: "192.168.1.10",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
snmp_enabled: false
|
|
})
|
|
|
|
# Ensure no jobs exist initially (created by create_device)
|
|
Repo.delete_all(Oban.Job)
|
|
|
|
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
|
|
|
# Should NOT schedule next poll (because it returns :ok early in maybe_poll_device,
|
|
# but wait, does it schedule next poll if skipped?
|
|
# perform/1 calls maybe_poll_device then schedule_next_poll_with_error_handling.
|
|
# Let's check the code of perform/1 again.
|
|
# It calls maybe_poll_device(device) -> returns :ok
|
|
# Then calls schedule_next_poll_with_error_handling(device_id, device)
|
|
|
|
# So it SHOULD schedule the next poll even if skipped?
|
|
# Logic:
|
|
# device ->
|
|
# maybe_poll_device(device)
|
|
# schedule_next_poll_with_error_handling(device_id, device)
|
|
# :ok
|
|
|
|
# Yes, it schedules next poll.
|
|
assert Repo.aggregate(Oban.Job, :count) == 1
|
|
end
|
|
|
|
test "polls device and schedules next run", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Router Poll",
|
|
ip_address: "192.168.1.11",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
snmp_enabled: true,
|
|
check_interval_seconds: 60
|
|
})
|
|
|
|
# Clear existing jobs
|
|
Repo.delete_all(Oban.Job)
|
|
|
|
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
|
|
|
# Verify next job scheduled
|
|
assert Repo.aggregate(Oban.Job, :count) == 1
|
|
end
|
|
end
|
|
|
|
describe "polling logic" do
|
|
test "polls sensors and updates values", %{site: site} do
|
|
# 1. Create Device
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Sensor Device",
|
|
ip_address: "192.168.1.50",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
snmp_enabled: true
|
|
})
|
|
|
|
# 2. Create SnmpDevice association
|
|
{:ok, snmp_device} =
|
|
Repo.insert(%Device{
|
|
device_id: device.id,
|
|
sys_object_id: "1.3.6.1.4.1.9.1",
|
|
sys_descr: "Cisco Router",
|
|
sys_name: "router01"
|
|
})
|
|
|
|
# 3. Create Sensor
|
|
{:ok, sensor} =
|
|
Repo.insert(%Sensor{
|
|
snmp_device_id: snmp_device.id,
|
|
sensor_type: "temperature",
|
|
sensor_index: "1",
|
|
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
|
sensor_descr: "Chassis Temp",
|
|
sensor_unit: "C",
|
|
sensor_divisor: 1,
|
|
monitored: true
|
|
})
|
|
|
|
# 4. Create Interface
|
|
{:ok, interface} =
|
|
Repo.insert(%Interface{
|
|
snmp_device_id: snmp_device.id,
|
|
if_index: 2,
|
|
if_name: "eth0",
|
|
if_descr: "Ethernet 0",
|
|
if_type: 6,
|
|
if_speed: 100_000_000,
|
|
if_phys_address: "00:11:22:33:44:55",
|
|
if_admin_status: "up",
|
|
if_oper_status: "up",
|
|
monitored: true
|
|
})
|
|
|
|
# 4b. Create State Sensor
|
|
{:ok, state_sensor} =
|
|
Repo.insert(%StateSensor{
|
|
snmp_device_id: snmp_device.id,
|
|
sensor_descr: "Power Supply 1",
|
|
sensor_oid: "1.3.6.1.4.1.9.9.13.1.5.1.3.1",
|
|
sensor_index: "1",
|
|
metadata: %{"states" => %{"1" => "unknown", "2" => "enabled", "3" => "disabled"}}
|
|
# status defaults to unknown
|
|
})
|
|
|
|
# 4c. Create Processor
|
|
{:ok, processor} =
|
|
Repo.insert(%Towerops.Snmp.Processor{
|
|
snmp_device_id: snmp_device.id,
|
|
processor_index: "1",
|
|
processor_type: "hr_processor",
|
|
description: "CPU 1"
|
|
})
|
|
|
|
# 5. Mock SNMP response
|
|
# The worker runs tasks in parallel, so we need to be careful with expectations.
|
|
# It calls poll_device_sensors, poll_device_state_sensors, etc.
|
|
# We only have one sensor, so poll_device_sensors will try to fetch it.
|
|
|
|
SnmpMock
|
|
|> stub(:get, fn _target, oid, _opts ->
|
|
case oid do
|
|
# Sensor
|
|
"1.3.6.1.2.1.99.1.1.1.4.1" -> {:ok, 45}
|
|
# State Sensor
|
|
# enabled/ok
|
|
"1.3.6.1.4.1.9.9.13.1.5.1.3.1" -> {:ok, 2}
|
|
# Processor Load
|
|
# 25% load
|
|
"1.3.6.1.2.1.25.3.3.1.2.1" -> {:ok, 25}
|
|
# Interface HC In Octets
|
|
"1.3.6.1.2.1.31.1.1.1.6.2" -> {:ok, 1000}
|
|
# Interface HC Out Octets
|
|
"1.3.6.1.2.1.31.1.1.1.10.2" -> {:ok, 2000}
|
|
# Interface Errors
|
|
"1.3.6.1.2.1.2.2.1.14.2" -> {:ok, 0}
|
|
"1.3.6.1.2.1.2.2.1.20.2" -> {:ok, 0}
|
|
"1.3.6.1.2.1.2.2.1.13.2" -> {:ok, 0}
|
|
"1.3.6.1.2.1.2.2.1.19.2" -> {:ok, 0}
|
|
# Interface change detection OIDs (for interface 2)
|
|
# Speed
|
|
"1.3.6.1.2.1.2.2.1.5.2" -> {:ok, 100_000_000}
|
|
# Physical address (MAC)
|
|
"1.3.6.1.2.1.2.2.1.6.2" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
|
|
# Admin status (down=2)
|
|
"1.3.6.1.2.1.2.2.1.7.2" -> {:ok, 2}
|
|
# Oper status (up=1)
|
|
"1.3.6.1.2.1.2.2.1.8.2" -> {:ok, 1}
|
|
_ -> {:error, :no_such_object}
|
|
end
|
|
end)
|
|
# Allow other calls (like interfaces, neighbors etc) to return empty/ok
|
|
|> stub(:walk, fn _target, _oid, _opts -> {:ok, []} end)
|
|
|
|
# 6. Run Perform
|
|
Repo.delete_all(Oban.Job)
|
|
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
|
|
|
# 7. Verify Sensor Updated
|
|
updated_sensor = Repo.get(Sensor, sensor.id)
|
|
assert updated_sensor.last_value == 45.0
|
|
assert updated_sensor.last_checked_at
|
|
|
|
# 8. Verify Interface Stats
|
|
stats = Repo.all(Towerops.Snmp.InterfaceStat)
|
|
assert length(stats) >= 1
|
|
stat = List.last(stats)
|
|
assert stat.interface_id == interface.id
|
|
assert stat.if_in_octets == 1000
|
|
assert stat.if_out_octets == 2000
|
|
|
|
# 9. Verify Interface Changed
|
|
updated_interface = Repo.get(Interface, interface.id)
|
|
assert updated_interface.if_admin_status == "down"
|
|
|
|
# 10. Verify State Sensor
|
|
updated_state = Repo.get(StateSensor, state_sensor.id)
|
|
assert updated_state.state_value == 2
|
|
assert updated_state.status == "ok"
|
|
assert updated_state.state_descr == "enabled"
|
|
|
|
# 11. Verify Processor
|
|
readings = Repo.all(Towerops.Snmp.ProcessorReading)
|
|
assert length(readings) >= 1
|
|
reading = List.last(readings)
|
|
assert reading.processor_id == processor.id
|
|
assert reading.load_percent == 25.0
|
|
end
|
|
|
|
test "handles SNMP errors gracefully", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Error Device",
|
|
ip_address: "192.168.1.51",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
snmp_enabled: true
|
|
})
|
|
|
|
{:ok, snmp_device} = Repo.insert(%Device{device_id: device.id})
|
|
|
|
{:ok, _sensor} =
|
|
Repo.insert(%Sensor{
|
|
snmp_device_id: snmp_device.id,
|
|
sensor_type: "temperature",
|
|
sensor_index: "1",
|
|
sensor_oid: "1.3.6.1.2.1.99.1.1.1.4.1",
|
|
sensor_descr: "Chassis Temp"
|
|
})
|
|
|
|
# Mock timeout
|
|
SnmpMock
|
|
|> expect(:get, fn _target, "1.3.6.1.2.1.99.1.1.1.4.1", _opts ->
|
|
{:error, :timeout}
|
|
end)
|
|
|> stub(:walk, fn _, _, _ -> {:ok, []} end)
|
|
|> stub(:get, fn _, _, _ -> {:error, :no_such_object} end)
|
|
|
|
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
|
|
end
|
|
end
|
|
|
|
describe "start_polling/1" do
|
|
test "schedules initial job with offset", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Router 1",
|
|
ip_address: "192.168.1.1",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
check_interval_seconds: 300
|
|
})
|
|
|
|
# Get the expected offset
|
|
expected_offset = PollingOffset.calculate_offset(device.id, 300)
|
|
|
|
# Start polling
|
|
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
|
|
|
# Verify job is scheduled with offset
|
|
assert job.args["device_id"] == device.id
|
|
assert job.scheduled_at
|
|
|
|
# Calculate the delay (scheduled_at - inserted_at)
|
|
delay_seconds = DateTime.diff(job.scheduled_at, job.inserted_at, :second)
|
|
assert delay_seconds == expected_offset
|
|
end
|
|
|
|
test "schedules initial job with offset for different interval", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Router 2",
|
|
ip_address: "192.168.1.2",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id,
|
|
check_interval_seconds: 600
|
|
})
|
|
|
|
# Get the expected offset
|
|
expected_offset = PollingOffset.calculate_offset(device.id, 600)
|
|
|
|
# Start polling
|
|
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
|
|
|
# Verify job is scheduled with offset
|
|
delay_seconds = DateTime.diff(job.scheduled_at, job.inserted_at, :second)
|
|
assert delay_seconds == expected_offset
|
|
end
|
|
end
|
|
|
|
describe "stop_polling/1" do
|
|
test "cancels all jobs for device", %{site: site} do
|
|
{:ok, device} =
|
|
Devices.create_device(%{
|
|
name: "Router 3",
|
|
ip_address: "192.168.1.3",
|
|
site_id: site.id,
|
|
organization_id: site.organization_id
|
|
})
|
|
|
|
# Start polling
|
|
assert {:ok, _job} = DevicePollerWorker.start_polling(device.id)
|
|
|
|
# Stop polling
|
|
assert {:ok, cancelled_jobs} = DevicePollerWorker.stop_polling(device.id)
|
|
refute cancelled_jobs == []
|
|
end
|
|
end
|
|
end
|