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
660 lines
21 KiB
Elixir
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
|