towerops/test/snmpkit/snmp_lib/error_handler_test.exs
Graham McIntire 07336fefff
perf(test): optimize slow tests - 68% faster
- Add timeout: 100 to SNMP tests to avoid 5s default timeout on unreachable IPs
- Reduce retry delays in error handler tests from 50-100ms to 1ms
- Fix Splynx rate limit test to stub auth immediately without extra calls
- Use localhost instead of device.local to avoid DNS timeout
- Use temp empty dir for ImportProfiles test instead of scanning 788 real files

Results:
- Top 10 slowest: 45.6s → 14.6s (68% reduction)
- Overall suite: ~80s → 39.9s (50% faster)
- Specific improvements:
  * Splynx sync: 7010ms → 134ms (98% faster)
  * ImportProfiles: 5502ms → 33ms (99% faster)
  * get_bulk!: 5043ms → 125ms (97% faster)
  * Walk hostname: 5005ms → 119ms (97% faster)
  * Error retries: 3103ms → 6ms (99% faster)
2026-03-05 14:35:58 -06:00

323 lines
9.8 KiB
Elixir

defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
use ExUnit.Case, async: true
alias SnmpKit.SnmpLib.ErrorHandler
describe "with_retry/2" do
test "succeeds on first attempt" do
fun = fn -> {:ok, :success} end
assert {:ok, :success} = ErrorHandler.with_retry(fun)
end
test "retries transient errors" do
# Simulate transient error then success
agent = Agent.start_link(fn -> 0 end)
{:ok, pid} = agent
fun = fn ->
count = Agent.get_and_update(pid, fn c -> {c, c + 1} end)
if count < 2 do
{:error, :timeout}
else
{:ok, :success_after_retry}
end
end
assert {:ok, :success_after_retry} = ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 1)
Agent.stop(pid)
end
test "fails after max retries" do
fun = fn -> {:error, :timeout} end
assert {:error, {:max_retries_exceeded, :timeout}} =
ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 1)
end
test "does not retry permanent errors" do
fun = fn -> {:error, :authentication_failed} end
# Custom retry condition that returns false for permanent errors
retry_condition = fn
:authentication_failed -> false
_ -> true
end
assert {:error, :authentication_failed} =
ErrorHandler.with_retry(fun,
max_attempts: 3,
retry_condition: retry_condition
)
end
test "handles exceptions with retry" do
agent = Agent.start_link(fn -> 0 end)
{:ok, pid} = agent
fun = fn ->
count = Agent.get_and_update(pid, fn c -> {c, c + 1} end)
if count < 2 do
raise "temporary failure"
else
{:ok, :recovered}
end
end
assert {:ok, :recovered} = ErrorHandler.with_retry(fun, max_attempts: 3, base_delay: 10)
Agent.stop(pid)
end
test "respects custom base delay" do
fun = fn -> {:error, :timeout} end
start_time = System.monotonic_time(:millisecond)
ErrorHandler.with_retry(fun,
max_attempts: 2,
base_delay: 100,
strategy: :fixed,
jitter_factor: 0
)
end_time = System.monotonic_time(:millisecond)
# Should have at least one 100ms delay (allow for some timing variance)
assert end_time - start_time >= 90
end
test "uses exponential backoff strategy" do
attempts = Agent.start_link(fn -> [] end)
{:ok, pid} = attempts
fun = fn ->
Agent.update(pid, fn list -> [System.monotonic_time(:millisecond) | list] end)
{:error, :timeout}
end
ErrorHandler.with_retry(fun,
max_attempts: 3,
base_delay: 50,
strategy: :exponential,
jitter_factor: 0
)
timestamps = Agent.get(pid, fn list -> Enum.reverse(list) end)
# Verify exponential backoff pattern (roughly)
assert length(timestamps) == 3
Agent.stop(pid)
end
test "uses linear backoff strategy" do
fun = fn -> {:error, :timeout} end
result =
ErrorHandler.with_retry(fun,
max_attempts: 3,
base_delay: 10,
strategy: :linear
)
assert {:error, {:max_retries_exceeded, :timeout}} = result
end
end
describe "classify_error/1" do
test "classifies transient network errors" do
assert :transient = ErrorHandler.classify_error(:timeout)
assert :transient = ErrorHandler.classify_error(:nxdomain)
assert :transient = ErrorHandler.classify_error(:network_unreachable)
assert :transient = ErrorHandler.classify_error(:connection_refused)
end
test "classifies transient device errors" do
assert :transient = ErrorHandler.classify_error(:device_busy)
assert :transient = ErrorHandler.classify_error(:too_big)
assert :transient = ErrorHandler.classify_error(:resource_unavailable)
end
test "classifies permanent configuration errors" do
assert :permanent = ErrorHandler.classify_error(:authentication_failed)
assert :permanent = ErrorHandler.classify_error(:community_mismatch)
assert :permanent = ErrorHandler.classify_error(:unsupported_version)
assert :permanent = ErrorHandler.classify_error(:no_such_name)
assert :permanent = ErrorHandler.classify_error(:bad_value)
assert :permanent = ErrorHandler.classify_error(:read_only)
end
test "classifies degraded performance errors" do
assert :degraded = ErrorHandler.classify_error(:slow_response)
assert :degraded = ErrorHandler.classify_error(:partial_failure)
assert :degraded = ErrorHandler.classify_error(:high_error_rate)
end
test "classifies unknown errors" do
assert :unknown = ErrorHandler.classify_error(:unknown_error)
assert :unknown = ErrorHandler.classify_error(:something_weird)
end
test "classifies network errors" do
assert :transient = ErrorHandler.classify_error({:network_error, "connection reset"})
end
end
describe "circuit breaker" do
test "starts circuit breaker successfully" do
assert {:ok, pid} = ErrorHandler.start_circuit_breaker("test-device-1")
assert Process.alive?(pid)
GenServer.stop(pid)
end
test "executes function when circuit is closed" do
{:ok, breaker} = ErrorHandler.start_circuit_breaker("test-device-2")
fun = fn -> {:ok, :result} end
assert {:ok, :result} = ErrorHandler.call_through_breaker(breaker, fun)
GenServer.stop(breaker)
end
test "opens circuit after failure threshold" do
{:ok, breaker} =
ErrorHandler.start_circuit_breaker("test-device-3", failure_threshold: 2)
failing_fun = fn -> {:error, :device_down} end
# First failure
assert {:error, :device_down} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Second failure - should open circuit
assert {:error, :device_down} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Circuit should be open now
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
GenServer.stop(breaker)
end
test "transitions from closed to half-open after recovery timeout" do
{:ok, breaker} =
ErrorHandler.start_circuit_breaker("test-device-4",
failure_threshold: 1,
recovery_timeout: 100
)
failing_fun = fn -> {:error, :timeout} end
# Trigger failure to open circuit
assert {:error, :timeout} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Circuit should be open
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Wait for recovery timeout
Process.sleep(150)
# Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end
assert {:ok, :recovered} = ErrorHandler.call_through_breaker(breaker, success_fun)
GenServer.stop(breaker)
end
test "resets circuit breaker" do
{:ok, breaker} = ErrorHandler.start_circuit_breaker("test-device-5")
# Get initial state
state = GenServer.call(breaker, :get_state)
assert state == :closed
# Reset
assert :ok = GenServer.call(breaker, :reset)
# Verify still closed
state = GenServer.call(breaker, :get_state)
assert state == :closed
GenServer.stop(breaker)
end
end
describe "adaptive_timeout/2" do
test "returns base timeout for unknown device" do
timeout = ErrorHandler.adaptive_timeout("invalid.device", base_timeout: 5000)
assert timeout == 5000
end
test "returns base timeout for nil device" do
timeout = ErrorHandler.adaptive_timeout(nil, base_timeout: 5000)
assert timeout == 5000
end
test "returns base timeout for empty device" do
timeout = ErrorHandler.adaptive_timeout("", base_timeout: 5000)
assert timeout == 5000
end
test "calculates adaptive timeout for known device" do
timeout = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, max_timeout: 60_000)
# Should be at least base timeout
assert timeout >= 1000
# Should not exceed max timeout
assert timeout <= 60_000
end
test "respects custom safety factor" do
timeout1 = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, safety_factor: 1.0)
timeout2 = ErrorHandler.adaptive_timeout("192.168.1.1", base_timeout: 1000, safety_factor: 3.0)
# Higher safety factor should give higher timeout (or at minimum be >= base)
assert timeout2 >= timeout1
end
end
describe "get_device_stats/1" do
test "returns placeholder stats for valid device" do
assert {:ok, stats} = ErrorHandler.get_device_stats("192.168.1.1")
assert stats.device_id == "192.168.1.1"
assert stats.success_count == 100
assert stats.failure_count == 5
assert stats.circuit_state == :closed
assert stats.quarantine_until == nil
end
test "returns error for nil device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats(nil)
end
test "returns error for empty device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats("")
end
test "returns error for invalid device" do
assert {:error, :not_found} = ErrorHandler.get_device_stats("invalid.device")
end
end
describe "quarantine_device/2" do
test "quarantines device successfully" do
assert :ok = ErrorHandler.quarantine_device("192.168.1.1", 300_000)
end
end
describe "quarantined?/1" do
test "returns false for valid device" do
refute ErrorHandler.quarantined?("192.168.1.1")
end
test "returns false for unknown device" do
refute ErrorHandler.quarantined?("invalid.device")
end
test "returns false for nil device" do
refute ErrorHandler.quarantined?(nil)
end
end
end