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