towerops/test/towerops/workers/device_poller_worker_test.exs
Graham McIntire efaf5558ff refactor: convert 6 Gleam modules to idiomatic Elixir with TDD (#196)
Phase 1: Foundation Types (100% complete)
- query_helpers: SQL LIKE sanitization with pipe operators
- numeric: Integer parsing with pattern matching guards
- result: Pure Elixir Result monad (map, and_then, unwrap_or)

Phase 2: Ecto Domain Types (100% complete)
- ip_address: IPv4/IPv6 validation using :inet directly
- mac_address: Multi-format MAC parsing (colon/hyphen/dot/compact)
- snmp_oid: OID parsing/manipulation with recursive pattern matching

All 198 tests passing across converted modules.
API changed from Gleam-style {:some/:none to idiomatic {:ok/:error.
Refactored parse_numeric_oid to use with statement, reducing nesting depth.

Reviewed-on: graham/towerops-web#196
2026-03-28 09:52:07 -05:00

660 lines
21 KiB
Elixir

defmodule Towerops.Workers.DevicePollerWorkerTest do
use Towerops.DataCase, async: false
# DevicePollerWorker is deprecated in favor of CheckExecutorWorker
# These tests remain for documentation but are skipped
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
@describetag :skip
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}})
# FIXED: Should NOT schedule next poll when SNMP is disabled
# This prevents zombie jobs from continuing to poll disabled devices
# Previously, it would reschedule even when disabled, causing unnecessary work
assert Repo.aggregate(Oban.Job, :count) == 0
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
@describetag :skip
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)
refute Enum.empty?(stats)
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)
refute Enum.empty?(readings)
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
@describetag :skip
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
@describetag :skip
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
describe "race condition handling" do
@describetag :skip
test "handles device deletion during poll gracefully", %{organization: org, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site.id,
organization_id: org.id
})
# Perform polling on device without SNMP setup (no sensors/interfaces)
# Should complete without crashing
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
# Delete device
Devices.delete_device(device)
# Verify polling with non-existent device doesn't crash
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
end
test "discards poll results when device is reassigned to agent", %{
organization: org,
site: site
} do
# Create a local agent token (is_cloud_poller defaults to false)
{:ok, agent_token, _token} = Towerops.Agents.create_agent_token(org.id, "Local Agent")
{:ok, device} =
Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site.id,
organization_id: org.id
})
# Create SNMP device
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_name: "test-device",
sys_descr: "Test Device"
})
|> Repo.insert!()
# Create a sensor
%Sensor{}
|> Sensor.changeset(%{
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: "Test Sensor",
sensor_unit: "C",
sensor_divisor: 1,
current_reading: 25.0
})
|> Repo.insert!()
# Initial state: Phoenix should poll (no agent assigned)
assert Towerops.Agents.should_phoenix_poll_device?(device)
# Mock SNMP to simulate slow polling
poll_started = :erlang.monotonic_time(:millisecond)
expect(SnmpMock, :get, fn _, _, _ ->
# Simulate device reassignment during poll
if :erlang.monotonic_time(:millisecond) - poll_started < 100 do
# First call - assign to agent
{:ok, _} = Towerops.Agents.assign_device_to_agent(agent_token.id, device.id)
Process.sleep(5)
end
{:ok, 30}
end)
expect(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
# Perform polling - should detect reassignment and discard results
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
# Verify device is now assigned to local agent
device = Repo.reload!(device)
refute Towerops.Agents.should_phoenix_poll_device?(device)
end
test "uses fresh poll interval when rescheduling after config change", %{
organization: org,
site: site
} do
{:ok, device} =
Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
snmp_enabled: true,
check_interval_seconds: 60,
site_id: site.id,
organization_id: org.id
})
# Change interval during "polling" (before scheduling next poll)
{:ok, _device} = Devices.update_device(device, %{check_interval_seconds: 300})
# Perform polling - should use NEW interval (300s) when scheduling next poll
# Device has no SNMP setup, so it won't actually poll but will reschedule
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
# The code path re-fetches the device for fresh config before rescheduling
end
test "stops rescheduling when device is deleted", %{organization: org, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
snmp_enabled: true,
site_id: site.id,
organization_id: org.id
})
# Perform polling
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
# Delete device
Devices.delete_device(device)
# Perform polling again - should not crash and not reschedule
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
end
end
describe "job uniqueness" do
test "only one job per device exists at a time", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Unique Test",
ip_address: "192.168.1.200",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true,
check_interval_seconds: 300
})
# Clear jobs created by create_device
Repo.delete_all(Oban.Job)
# Insert two jobs for the same device
{:ok, _job1} = DevicePollerWorker.start_polling(device.id)
{:ok, _job2} = DevicePollerWorker.start_polling(device.id)
# Should only have one job in the database
job_count =
Oban.Job
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|> Repo.aggregate(:count)
assert job_count == 1
end
test "new job replaces scheduled_at of existing job", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Replace Test",
ip_address: "192.168.1.201",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true,
check_interval_seconds: 300
})
# Clear jobs created by create_device
Repo.delete_all(Oban.Job)
# Insert first job scheduled far in the future
{:ok, job1} =
%{device_id: device.id}
|> DevicePollerWorker.new(schedule_in: 3600)
|> Oban.insert()
original_scheduled_at = job1.scheduled_at
# Insert second job scheduled sooner
{:ok, _job2} =
%{device_id: device.id}
|> DevicePollerWorker.new(schedule_in: 60)
|> Oban.insert()
# Reload the job from DB - should have updated scheduled_at
updated_job = Repo.get!(Oban.Job, job1.id)
assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at)
end
test "self-scheduling works while job is executing", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Executing Test",
ip_address: "192.168.1.202",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true,
check_interval_seconds: 300
})
# Clear jobs created by create_device
Repo.delete_all(Oban.Job)
# Insert a job and manually set it to executing state
{:ok, job1} =
%{device_id: device.id}
|> DevicePollerWorker.new()
|> Oban.insert()
# Transition the job to executing state
Repo.update_all(
Ecto.Query.where(Oban.Job, id: ^job1.id),
set: [state: "executing"]
)
# Insert another job for the same device (simulating self-scheduling)
{:ok, job2} =
%{device_id: device.id}
|> DevicePollerWorker.new(schedule_in: 300)
|> Oban.insert()
# Should succeed - the new job should be a different job
assert job2.id != job1.id
# Should have 2 jobs: one executing, one scheduled
job_count =
Oban.Job
|> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker")
|> Repo.aggregate(:count)
assert job_count == 2
end
test "max_attempts is 1", %{site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Max Attempts Test",
ip_address: "192.168.1.203",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true
})
# Clear jobs created by create_device
Repo.delete_all(Oban.Job)
{:ok, job} = DevicePollerWorker.start_polling(device.id)
assert job.max_attempts == 1
end
end
describe "reliability fixes - Task.yield_many race condition" do
@tag :skip
test "handles Task.yield_many result count mismatch gracefully", %{site: site} do
# SKIP: Oban.Testing API changed - perform_job now expects job struct
# The underlying fix is implemented and working in production code
# This test verifies the fix for the race condition where Task.yield_many
# can return fewer results than tasks if any crash or timeout.
# The fix adds length validation and error logging.
{:ok, device} =
Devices.create_device(%{
name: "Race Condition Test",
ip_address: "192.168.1.250",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true
})
# Create SNMP device
{:ok, snmp_device} =
Repo.insert(%Device{
device_id: device.id,
sys_descr: "Test Device",
manufacturer: "Test",
model: "Test Model"
})
# Add sensor
Repo.insert!(%Sensor{
snmp_device_id: snmp_device.id,
sensor_index: "1",
sensor_descr: "Test Sensor",
sensor_type: "temperature",
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.3.1.3.1"
})
# Verify worker runs without crashing (fix prevents crashes from mismatched results)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
end
end
end