"""Unit tests for the UniFi poll scheduler's client lifecycle (no network/DB). The leak guard: the module-cached UnifiClient holds a long-lived httpx pool, so whenever the scheduler discards it (login failure, or a mid-poll error that forces re-auth) it MUST close it first — otherwise every failure tick orphans a connection pool and leaks file descriptors. These tests drive both discard paths and assert the client was closed and the cache cleared. Uses asyncio.run() directly so it doesn't depend on the pytest-asyncio mode. """ import asyncio import types import plugins.unifi.client as client_mod import plugins.unifi.scheduler as scheduler def _make_app(): app = types.SimpleNamespace() app.config = {"PLUGINS": {"unifi": {"host": "h", "username": "u", "password": "p"}}} return app class _FakeClient: """Stand-in for UnifiClient; records close() and can fail on demand.""" created: list["_FakeClient"] = [] login_fails = False health_fails = False def __init__(self, **_kwargs): self.closed = False _FakeClient.created.append(self) async def login(self): if _FakeClient.login_fails: raise RuntimeError("login boom") async def get_health(self): if _FakeClient.health_fails: raise RuntimeError("health boom") return [] async def close(self): self.closed = True def _install(monkeypatch): monkeypatch.setattr(client_mod, "UnifiClient", _FakeClient) monkeypatch.setattr(scheduler, "_client", None) _FakeClient.created = [] _FakeClient.login_fails = False _FakeClient.health_fails = False def test_login_failure_closes_and_clears_client(monkeypatch): _install(monkeypatch) _FakeClient.login_fails = True asyncio.run(scheduler._do_poll(_make_app())) assert len(_FakeClient.created) == 1 assert _FakeClient.created[0].closed is True # closed, not just dropped assert scheduler._client is None # cache cleared → re-auth next tick def test_poll_failure_closes_and_clears_client(monkeypatch): _install(monkeypatch) _FakeClient.health_fails = True # login OK, first API call blows up asyncio.run(scheduler._do_poll(_make_app())) assert len(_FakeClient.created) == 1 assert _FakeClient.created[0].closed is True assert scheduler._client is None def test_drop_client_is_noop_when_unset(monkeypatch): _install(monkeypatch) # No client cached — dropping must not raise. asyncio.run(scheduler._drop_client()) assert scheduler._client is None def test_drop_client_swallows_close_errors(monkeypatch): _install(monkeypatch) class _Boom: async def close(self): raise OSError("close boom") monkeypatch.setattr(scheduler, "_client", _Boom()) asyncio.run(scheduler._drop_client()) # must not propagate assert scheduler._client is None