test_fritzbox_plugin.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests fuer das Fritzbox Plugin.
  4. Testet Monitor-Klasse, Intent-Handler und Monitoring-Logik
  5. mit gemockter fritzconnection-Bibliothek.
  6. """
  7. import asyncio
  8. import time
  9. from datetime import datetime
  10. from pathlib import Path
  11. from unittest.mock import AsyncMock, MagicMock, patch
  12. import pytest
  13. from trixy_core.nlp.handler import IntentReceivedData, IntentResult
  14. from plugins.fritzbox.monitor import (
  15. FritzboxMonitor,
  16. TrafficSnapshot,
  17. NetworkDevice,
  18. )
  19. from plugins.fritzbox.main import FritzboxPlugin
  20. # ============================================================================
  21. # Fixtures
  22. # ============================================================================
  23. def _make_traffic(upload_mbps: float = 1.0, download_mbps: float = 10.0) -> TrafficSnapshot:
  24. """Erstellt ein TrafficSnapshot mit Mbit/s Werten."""
  25. return TrafficSnapshot(
  26. timestamp=datetime.now(),
  27. upload_bytes_sec=upload_mbps * 1_000_000 / 8,
  28. download_bytes_sec=download_mbps * 1_000_000 / 8,
  29. )
  30. def _make_devices(count: int = 3) -> list[NetworkDevice]:
  31. """Erstellt eine Liste von Netzwerkgeraeten."""
  32. return [
  33. NetworkDevice(
  34. name=f"Device-{i}",
  35. ip=f"192.168.178.{10 + i}",
  36. mac=f"AA:BB:CC:DD:EE:{i:02X}",
  37. active=True,
  38. interface="LAN",
  39. )
  40. for i in range(count)
  41. ]
  42. def _make_plugin(tmp_path, config_overrides=None) -> tuple[FritzboxPlugin, MagicMock]:
  43. """Erstellt ein FritzboxPlugin mit Mock-Application."""
  44. mock_app = MagicMock()
  45. mock_app.events = MagicMock()
  46. mock_app.events.emit = AsyncMock()
  47. mock_app.events.trigger = AsyncMock()
  48. mock_app.events.register = MagicMock()
  49. mock_app.debug = True
  50. mock_app.services = MagicMock()
  51. mock_app.services.get_service = MagicMock(return_value=None)
  52. # Satellites fuer Benachrichtigungen
  53. mock_app.satellites = MagicMock()
  54. config = {
  55. "enabled": True,
  56. "fritzbox": {
  57. "host": "192.168.178.1",
  58. "port": 49000,
  59. "username": "admin",
  60. "password": "secret",
  61. "use_tls": False,
  62. },
  63. "check_interval_seconds": 30,
  64. "known_devices": [],
  65. "alerts": {
  66. "high_upload": {"enabled": True, "threshold_mbit": 5.0, "message": "Hoher Upload: {rate} Mbit/s"},
  67. "high_download": {"enabled": True, "threshold_mbit": 50.0, "message": "Hoher Download: {rate} Mbit/s"},
  68. "new_device": {"enabled": True, "message": "Neues Geraet: {name} ({ip})"},
  69. "device_offline": {"enabled": False, "watch_devices": [], "message": "Geraet offline: {name}"},
  70. },
  71. "notify_satellites": {"mode": "all", "aliases": [], "rooms": []},
  72. "notification_sound": "notification.wav",
  73. }
  74. if config_overrides:
  75. config.update(config_overrides)
  76. plugin_path = tmp_path / "fritzbox"
  77. plugin_path.mkdir(exist_ok=True)
  78. plugin = FritzboxPlugin(
  79. application=mock_app,
  80. plugin_path=plugin_path,
  81. config=config,
  82. )
  83. return plugin, mock_app
  84. def _make_data(**kwargs) -> IntentReceivedData:
  85. """Erstellt IntentReceivedData mit Defaults."""
  86. defaults = {
  87. "intent": "fritzbox_status",
  88. "satellite_id": "sat-1",
  89. "room_id": "wohnzimmer",
  90. "is_authenticated": True,
  91. }
  92. defaults.update(kwargs)
  93. return IntentReceivedData(**defaults)
  94. # ============================================================================
  95. # Tests: TrafficSnapshot
  96. # ============================================================================
  97. class TestTrafficSnapshot:
  98. """Tests fuer TrafficSnapshot Datenklasse."""
  99. def test_upload_mbit_conversion(self):
  100. """upload_mbit rechnet Bytes/s korrekt in Mbit/s um."""
  101. snapshot = TrafficSnapshot(
  102. timestamp=datetime.now(),
  103. upload_bytes_sec=125000, # 1 Mbit/s
  104. download_bytes_sec=0,
  105. )
  106. assert abs(snapshot.upload_mbit - 1.0) < 0.01
  107. def test_download_mbit_conversion(self):
  108. """download_mbit rechnet Bytes/s korrekt in Mbit/s um."""
  109. snapshot = TrafficSnapshot(
  110. timestamp=datetime.now(),
  111. upload_bytes_sec=0,
  112. download_bytes_sec=6250000, # 50 Mbit/s
  113. )
  114. assert abs(snapshot.download_mbit - 50.0) < 0.01
  115. def test_zero_traffic(self):
  116. """Null-Traffic gibt 0 Mbit/s zurueck."""
  117. snapshot = TrafficSnapshot(timestamp=datetime.now())
  118. assert snapshot.upload_mbit == 0.0
  119. assert snapshot.download_mbit == 0.0
  120. # ============================================================================
  121. # Tests: NetworkDevice
  122. # ============================================================================
  123. class TestNetworkDevice:
  124. """Tests fuer NetworkDevice Datenklasse."""
  125. def test_creation(self):
  126. """NetworkDevice mit allen Feldern erstellen."""
  127. dev = NetworkDevice(
  128. name="iPhone", ip="192.168.178.42",
  129. mac="AA:BB:CC:DD:EE:FF", active=True, interface="WLAN",
  130. )
  131. assert dev.name == "iPhone"
  132. assert dev.active is True
  133. def test_default_interface(self):
  134. """NetworkDevice hat leeren Interface-Default."""
  135. dev = NetworkDevice(name="Test", ip="1.2.3.4", mac="00:00:00:00:00:00", active=False)
  136. assert dev.interface == ""
  137. # ============================================================================
  138. # Tests: FritzboxMonitor
  139. # ============================================================================
  140. class TestFritzboxMonitor:
  141. """Tests fuer FritzboxMonitor (mit gemockter fritzconnection)."""
  142. def test_is_available_without_lib(self):
  143. """is_available gibt False wenn fritzconnection fehlt."""
  144. with patch("plugins.fritzbox.monitor._HAS_FRITZ", False):
  145. monitor = FritzboxMonitor("192.168.178.1", 49000, "", "pass")
  146. assert monitor.is_available is False
  147. def test_is_available_with_lib(self):
  148. """is_available gibt True wenn fritzconnection vorhanden."""
  149. with patch("plugins.fritzbox.monitor._HAS_FRITZ", True):
  150. monitor = FritzboxMonitor("192.168.178.1", 49000, "", "pass")
  151. assert monitor.is_available is True
  152. def test_initial_state(self):
  153. """Monitor ist initial nicht verbunden."""
  154. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  155. assert monitor.is_connected is False
  156. def test_connect_without_lib(self):
  157. """connect() gibt False wenn fritzconnection fehlt."""
  158. with patch("plugins.fritzbox.monitor._HAS_FRITZ", False):
  159. monitor = FritzboxMonitor("192.168.178.1", 49000, "", "pass")
  160. assert monitor.connect() is False
  161. @patch("plugins.fritzbox.monitor._HAS_FRITZ", True)
  162. @patch("plugins.fritzbox.monitor.FritzConnection")
  163. @patch("plugins.fritzbox.monitor.FritzStatus")
  164. @patch("plugins.fritzbox.monitor.FritzHosts")
  165. def test_connect_success(self, mock_hosts, mock_status, mock_conn):
  166. """connect() setzt is_connected bei Erfolg."""
  167. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  168. assert monitor.connect() is True
  169. assert monitor.is_connected is True
  170. @patch("plugins.fritzbox.monitor._HAS_FRITZ", True)
  171. @patch("plugins.fritzbox.monitor.FritzConnection", side_effect=Exception("Timeout"))
  172. def test_connect_failure(self, mock_conn):
  173. """connect() gibt False bei Verbindungsfehler."""
  174. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  175. assert monitor.connect() is False
  176. assert monitor.is_connected is False
  177. def test_disconnect(self):
  178. """disconnect() setzt alles zurueck."""
  179. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  180. monitor._connected = True
  181. monitor._connection = MagicMock()
  182. monitor.disconnect()
  183. assert monitor.is_connected is False
  184. assert monitor._connection is None
  185. def test_get_traffic_not_connected(self):
  186. """get_traffic() ohne Verbindung gibt None."""
  187. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  188. assert monitor.get_traffic() is None
  189. def test_get_traffic_success(self):
  190. """get_traffic() liefert TrafficSnapshot."""
  191. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  192. mock_status = MagicMock()
  193. mock_status.transmission_rate = (125000, 6250000) # 1/50 Mbit
  194. monitor._status = mock_status
  195. result = monitor.get_traffic()
  196. assert result is not None
  197. assert abs(result.upload_mbit - 1.0) < 0.01
  198. assert abs(result.download_mbit - 50.0) < 0.01
  199. def test_get_traffic_exception(self):
  200. """get_traffic() gibt None bei Fehler."""
  201. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  202. mock_status = MagicMock()
  203. mock_status.transmission_rate = property(lambda self: (_ for _ in ()).throw(Exception("Fehler")))
  204. type(mock_status).transmission_rate = property(lambda self: (_ for _ in ()).throw(Exception("Fehler")))
  205. monitor._status = mock_status
  206. result = monitor.get_traffic()
  207. assert result is None
  208. def test_get_devices_not_connected(self):
  209. """get_devices() ohne Hosts gibt leere Liste."""
  210. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  211. assert monitor.get_devices() == []
  212. def test_get_devices_success(self):
  213. """get_devices() liefert Geraete-Liste."""
  214. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  215. mock_hosts = MagicMock()
  216. mock_hosts.host_numbers = 2
  217. mock_hosts.get_generic_host_entry = MagicMock(side_effect=[
  218. {"NewHostName": "PC", "NewIPAddress": "192.168.178.10",
  219. "NewMACAddress": "AA:BB:CC:DD:EE:01", "NewActive": True, "NewInterfaceType": "LAN"},
  220. {"NewHostName": "Phone", "NewIPAddress": "192.168.178.11",
  221. "NewMACAddress": "AA:BB:CC:DD:EE:02", "NewActive": False, "NewInterfaceType": "WLAN"},
  222. ])
  223. monitor._hosts = mock_hosts
  224. devices = monitor.get_devices()
  225. assert len(devices) == 2
  226. assert devices[0].name == "PC"
  227. assert devices[0].active is True
  228. assert devices[1].active is False
  229. def test_get_active_devices(self):
  230. """get_active_devices() filtert inaktive Geraete."""
  231. monitor = FritzboxMonitor("192.168.178.1", 49000, "user", "pass")
  232. mock_hosts = MagicMock()
  233. mock_hosts.host_numbers = 3
  234. mock_hosts.get_generic_host_entry = MagicMock(side_effect=[
  235. {"NewHostName": "A", "NewIPAddress": "1", "NewMACAddress": "M1", "NewActive": True},
  236. {"NewHostName": "B", "NewIPAddress": "2", "NewMACAddress": "M2", "NewActive": False},
  237. {"NewHostName": "C", "NewIPAddress": "3", "NewMACAddress": "M3", "NewActive": True},
  238. ])
  239. monitor._hosts = mock_hosts
  240. active = monitor.get_active_devices()
  241. assert len(active) == 2
  242. # ============================================================================
  243. # Tests: FritzboxPlugin — Intent-Handler
  244. # ============================================================================
  245. class TestFritzboxIntents:
  246. """Tests fuer die 3 Fritzbox-Intent-Handler."""
  247. @pytest.mark.asyncio
  248. async def test_handle_status_not_connected(self, tmp_path):
  249. """fritzbox_status bei nicht verbundener Fritzbox."""
  250. plugin, _ = _make_plugin(tmp_path)
  251. plugin._monitor = None
  252. data = _make_data(intent="fritzbox_status")
  253. result = await plugin.handle_status(data)
  254. assert result.success is False
  255. assert "nicht verbunden" in result.response_text
  256. @pytest.mark.asyncio
  257. async def test_handle_status_connected(self, tmp_path):
  258. """fritzbox_status mit Traffic und Geraeten."""
  259. plugin, _ = _make_plugin(tmp_path)
  260. mock_monitor = MagicMock()
  261. mock_monitor.is_connected = True
  262. mock_monitor.get_traffic = MagicMock(return_value=_make_traffic(2.5, 25.0))
  263. mock_monitor.get_active_devices = MagicMock(return_value=_make_devices(5))
  264. plugin._monitor = mock_monitor
  265. data = _make_data(intent="fritzbox_status")
  266. result = await plugin.handle_status(data)
  267. assert result.success is True
  268. assert "25.0" in result.response_text # Download
  269. assert "2.5" in result.response_text # Upload
  270. assert "5 Geraete" in result.response_text
  271. @pytest.mark.asyncio
  272. async def test_handle_status_no_traffic(self, tmp_path):
  273. """fritzbox_status ohne Traffic-Daten zeigt nur Geraete."""
  274. plugin, _ = _make_plugin(tmp_path)
  275. mock_monitor = MagicMock()
  276. mock_monitor.is_connected = True
  277. mock_monitor.get_traffic = MagicMock(return_value=None)
  278. mock_monitor.get_active_devices = MagicMock(return_value=_make_devices(2))
  279. plugin._monitor = mock_monitor
  280. data = _make_data(intent="fritzbox_status")
  281. result = await plugin.handle_status(data)
  282. assert result.success is True
  283. assert "2 Geraete" in result.response_text
  284. @pytest.mark.asyncio
  285. async def test_handle_devices_not_connected(self, tmp_path):
  286. """fritzbox_devices bei nicht verbundener Fritzbox."""
  287. plugin, _ = _make_plugin(tmp_path)
  288. plugin._monitor = MagicMock()
  289. plugin._monitor.is_connected = False
  290. data = _make_data(intent="fritzbox_devices")
  291. result = await plugin.handle_devices(data)
  292. assert result.success is False
  293. @pytest.mark.asyncio
  294. async def test_handle_devices_with_list(self, tmp_path):
  295. """fritzbox_devices listet Geraete auf."""
  296. plugin, _ = _make_plugin(tmp_path)
  297. mock_monitor = MagicMock()
  298. mock_monitor.is_connected = True
  299. mock_monitor.get_active_devices = MagicMock(return_value=_make_devices(3))
  300. plugin._monitor = mock_monitor
  301. data = _make_data(intent="fritzbox_devices")
  302. result = await plugin.handle_devices(data)
  303. assert result.success is True
  304. assert "3 Geraete" in result.response_text
  305. assert "Device-0" in result.response_text
  306. @pytest.mark.asyncio
  307. async def test_handle_devices_empty(self, tmp_path):
  308. """fritzbox_devices ohne Geraete."""
  309. plugin, _ = _make_plugin(tmp_path)
  310. mock_monitor = MagicMock()
  311. mock_monitor.is_connected = True
  312. mock_monitor.get_active_devices = MagicMock(return_value=[])
  313. plugin._monitor = mock_monitor
  314. data = _make_data(intent="fritzbox_devices")
  315. result = await plugin.handle_devices(data)
  316. assert result.success is True
  317. assert "keine" in result.response_text.lower()
  318. @pytest.mark.asyncio
  319. async def test_handle_devices_more_than_10(self, tmp_path):
  320. """fritzbox_devices mit mehr als 10 Geraeten zeigt 'und X weitere'."""
  321. plugin, _ = _make_plugin(tmp_path)
  322. mock_monitor = MagicMock()
  323. mock_monitor.is_connected = True
  324. mock_monitor.get_active_devices = MagicMock(return_value=_make_devices(15))
  325. plugin._monitor = mock_monitor
  326. data = _make_data(intent="fritzbox_devices")
  327. result = await plugin.handle_devices(data)
  328. assert result.success is True
  329. assert "15 Geraete" in result.response_text
  330. assert "5 weitere" in result.response_text
  331. @pytest.mark.asyncio
  332. async def test_handle_traffic_not_connected(self, tmp_path):
  333. """fritzbox_traffic bei nicht verbundener Fritzbox."""
  334. plugin, _ = _make_plugin(tmp_path)
  335. plugin._monitor = None
  336. data = _make_data(intent="fritzbox_traffic")
  337. result = await plugin.handle_traffic(data)
  338. assert result.success is False
  339. @pytest.mark.asyncio
  340. async def test_handle_traffic_success(self, tmp_path):
  341. """fritzbox_traffic zeigt Up-/Download-Raten."""
  342. plugin, _ = _make_plugin(tmp_path)
  343. mock_monitor = MagicMock()
  344. mock_monitor.is_connected = True
  345. mock_monitor.get_traffic = MagicMock(return_value=_make_traffic(3.2, 45.7))
  346. plugin._monitor = mock_monitor
  347. data = _make_data(intent="fritzbox_traffic")
  348. result = await plugin.handle_traffic(data)
  349. assert result.success is True
  350. assert "45.7" in result.response_text
  351. assert "3.2" in result.response_text
  352. @pytest.mark.asyncio
  353. async def test_handle_traffic_no_data(self, tmp_path):
  354. """fritzbox_traffic ohne Traffic-Daten."""
  355. plugin, _ = _make_plugin(tmp_path)
  356. mock_monitor = MagicMock()
  357. mock_monitor.is_connected = True
  358. mock_monitor.get_traffic = MagicMock(return_value=None)
  359. plugin._monitor = mock_monitor
  360. data = _make_data(intent="fritzbox_traffic")
  361. result = await plugin.handle_traffic(data)
  362. assert result.success is False
  363. assert "keine" in result.response_text.lower() or "konnte" in result.response_text.lower()
  364. # ============================================================================
  365. # Tests: Monitoring-Logik
  366. # ============================================================================
  367. class TestMonitoringLogic:
  368. """Tests fuer die internen Monitoring-Methoden."""
  369. @pytest.mark.asyncio
  370. async def test_check_traffic_upload_alert(self, tmp_path):
  371. """_check_traffic loest Upload-Warnung bei Ueberschreitung aus."""
  372. plugin, _ = _make_plugin(tmp_path)
  373. plugin._notify = AsyncMock()
  374. plugin._last_upload_alert = 0.0
  375. alerts = plugin.config["alerts"]
  376. traffic = _make_traffic(upload_mbps=10.0, download_mbps=1.0)
  377. await plugin._check_traffic(traffic, alerts)
  378. plugin._notify.assert_called_once()
  379. call_text = plugin._notify.call_args[0][0]
  380. assert "10.0" in call_text
  381. @pytest.mark.asyncio
  382. async def test_check_traffic_download_alert(self, tmp_path):
  383. """_check_traffic loest Download-Warnung bei Ueberschreitung aus."""
  384. plugin, _ = _make_plugin(tmp_path)
  385. plugin._notify = AsyncMock()
  386. plugin._last_download_alert = 0.0
  387. alerts = plugin.config["alerts"]
  388. traffic = _make_traffic(upload_mbps=0.1, download_mbps=100.0)
  389. await plugin._check_traffic(traffic, alerts)
  390. plugin._notify.assert_called_once()
  391. call_text = plugin._notify.call_args[0][0]
  392. assert "100.0" in call_text
  393. @pytest.mark.asyncio
  394. async def test_check_traffic_no_alert_below_threshold(self, tmp_path):
  395. """_check_traffic loest keine Warnung unter Schwellwert aus."""
  396. plugin, _ = _make_plugin(tmp_path)
  397. plugin._notify = AsyncMock()
  398. alerts = plugin.config["alerts"]
  399. traffic = _make_traffic(upload_mbps=1.0, download_mbps=10.0)
  400. await plugin._check_traffic(traffic, alerts)
  401. plugin._notify.assert_not_called()
  402. @pytest.mark.asyncio
  403. async def test_check_traffic_cooldown(self, tmp_path):
  404. """_check_traffic respektiert Cooldown zwischen Warnungen."""
  405. plugin, _ = _make_plugin(tmp_path)
  406. plugin._notify = AsyncMock()
  407. plugin._last_upload_alert = time.monotonic() # Gerade erst gewarnt
  408. alerts = plugin.config["alerts"]
  409. traffic = _make_traffic(upload_mbps=10.0, download_mbps=1.0)
  410. await plugin._check_traffic(traffic, alerts)
  411. plugin._notify.assert_not_called()
  412. @pytest.mark.asyncio
  413. async def test_check_devices_new_device(self, tmp_path):
  414. """_check_devices erkennt neues Geraet."""
  415. plugin, _ = _make_plugin(tmp_path)
  416. plugin._notify = AsyncMock()
  417. plugin.save_config = MagicMock()
  418. plugin._known_macs = {"AA:BB:CC:DD:EE:00"}
  419. devices = _make_devices(2)
  420. alerts = plugin.config["alerts"]
  421. await plugin._check_devices(devices, alerts)
  422. # Device-0 ist bekannt (MAC AA:BB:CC:DD:EE:00), Device-1 ist neu
  423. assert plugin._notify.call_count == 1
  424. assert "AA:BB:CC:DD:EE:01" in plugin._known_macs
  425. @pytest.mark.asyncio
  426. async def test_check_devices_all_known(self, tmp_path):
  427. """_check_devices bei nur bekannten Geraeten."""
  428. plugin, _ = _make_plugin(tmp_path)
  429. plugin._notify = AsyncMock()
  430. devices = _make_devices(2)
  431. plugin._known_macs = {d.mac.upper() for d in devices}
  432. alerts = plugin.config["alerts"]
  433. await plugin._check_devices(devices, alerts)
  434. plugin._notify.assert_not_called()
  435. @pytest.mark.asyncio
  436. async def test_check_devices_offline_detection(self, tmp_path):
  437. """_check_devices erkennt offline-gegangenes Geraet."""
  438. config_overrides = {
  439. "alerts": {
  440. "high_upload": {"enabled": False},
  441. "high_download": {"enabled": False},
  442. "new_device": {"enabled": False},
  443. "device_offline": {
  444. "enabled": True,
  445. "watch_devices": ["AA:BB:CC:DD:EE:FF"],
  446. "message": "Geraet offline: {name}",
  447. },
  448. },
  449. }
  450. plugin, _ = _make_plugin(tmp_path, config_overrides=config_overrides)
  451. plugin._notify = AsyncMock()
  452. # Leere Geraete-Liste → AA:BB:CC:DD:EE:FF ist offline
  453. devices: list[NetworkDevice] = []
  454. alerts = plugin.config["alerts"]
  455. await plugin._check_devices(devices, alerts)
  456. plugin._notify.assert_called_once()
  457. @pytest.mark.asyncio
  458. async def test_run_checks_not_connected(self, tmp_path):
  459. """_run_checks tut nichts wenn Monitor nicht verbunden."""
  460. plugin, _ = _make_plugin(tmp_path)
  461. plugin._monitor = MagicMock()
  462. plugin._monitor.is_connected = False
  463. plugin._check_traffic = AsyncMock()
  464. plugin._check_devices = AsyncMock()
  465. await plugin._run_checks()
  466. plugin._check_traffic.assert_not_called()
  467. plugin._check_devices.assert_not_called()
  468. # ============================================================================
  469. # Tests: Plugin Lifecycle
  470. # ============================================================================
  471. class TestPluginLifecycle:
  472. """Tests fuer on_load und on_unload."""
  473. @pytest.mark.asyncio
  474. async def test_on_load_no_password(self, tmp_path):
  475. """on_load ohne Passwort laedt keinen Monitor."""
  476. config = {
  477. "enabled": True,
  478. "fritzbox": {"host": "192.168.178.1", "password": ""},
  479. }
  480. plugin, _ = _make_plugin(tmp_path, config_overrides=config)
  481. await plugin.on_load()
  482. assert plugin._monitor is None
  483. @pytest.mark.asyncio
  484. async def test_on_unload_without_task(self, tmp_path):
  485. """on_unload ohne laufenden Task ist sicher."""
  486. plugin, _ = _make_plugin(tmp_path)
  487. plugin._check_task = None
  488. plugin._monitor = None
  489. await plugin.on_unload() # Kein Fehler
  490. @pytest.mark.asyncio
  491. async def test_on_unload_cancels_task(self, tmp_path):
  492. """on_unload bricht laufenden Check-Task ab."""
  493. plugin, _ = _make_plugin(tmp_path)
  494. async def fake_loop():
  495. await asyncio.sleep(999)
  496. plugin._check_task = asyncio.create_task(fake_loop())
  497. plugin._monitor = MagicMock()
  498. await plugin.on_unload()
  499. assert plugin._check_task.cancelled() or plugin._check_task.done()
  500. plugin._monitor.disconnect.assert_called_once()