"""Unit tests for the SNMP poller (no pysnmp / no network). The unit lane doesn't install the `snmp` extra, so these exercise the parts that don't need pysnmp: the version→mpModel mapping, that the poller is async, and the graceful "pysnmp missing → empty result" path. Live polling against a real device is verified out-of-band (CI has no SNMP target). Uses asyncio.run() directly so it doesn't depend on the pytest-asyncio mode. """ import asyncio import inspect from plugins.snmp import poller def test_poll_device_is_coroutine(): # The scheduler awaits it directly (no executor) — it must be async. assert inspect.iscoroutinefunction(poller.poll_device) def test_mp_model_maps_version_to_int(): assert poller._mp_model("1") == 0 # SNMP v1 assert poller._mp_model("2c") == 1 # SNMP v2c assert poller._mp_model("2") == 1 # anything non-"1" → v2c model def test_poll_device_without_pysnmp_returns_empty(monkeypatch): # When the optional dep is absent, polling is disabled gracefully (no raise). monkeypatch.setattr(poller, "_pysnmp_available", lambda: False) out = asyncio.run( poller.poll_device("192.0.2.1", 161, "public", "2c", [{"oid": "1.3.6.1.2.1.1.3.0"}]) ) assert out == {} # --- _close_engine: the fd-leak guard --------------------------------------- # A fresh SnmpEngine per poll leaks its UDP socket unless closed; _close_engine # must release it across both pysnmp API shapes. These fakes stand in for the # engine since the unit lane has no pysnmp. def test_close_engine_uses_engine_level_close_7x(): # canonical pysnmp 7.x: close method lives directly on the engine. calls = [] class Engine: def close_dispatcher(self): calls.append("engine") poller._close_engine(Engine()) assert calls == ["engine"] def test_close_engine_falls_back_to_dispatcher_6x(): # pysnmp-lextudio 6.2.x: no engine-level close; go through the dispatcher. calls = [] class Dispatcher: def closeDispatcher(self): calls.append("dispatcher") class Engine: transportDispatcher = Dispatcher() poller._close_engine(Engine()) assert calls == ["dispatcher"] def test_close_engine_never_raises(): # A failing close must not break the poll loop. class Engine: def close_dispatcher(self): raise OSError("boom") poller._close_engine(Engine()) # no exception # No close path at all (defensive) is also fine. poller._close_engine(object())