test_support_helpers.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. """Unit tests for support module helper functions.
  2. Tests _anonymize_mqtt_broker, _check_port, _get_container_memory_limit,
  3. _format_bytes, and _collect_support_info diagnostic sections.
  4. """
  5. import asyncio
  6. import tempfile
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. class TestApplyLogLevel:
  11. """Tests for _apply_log_level() debug noise suppression."""
  12. def test_debug_mode_suppresses_sqlalchemy_to_warning(self):
  13. """Verify sqlalchemy.engine is set to WARNING (not INFO) in debug mode."""
  14. import logging
  15. from backend.app.api.routes.support import _apply_log_level
  16. _apply_log_level(True)
  17. assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
  18. def test_debug_mode_suppresses_aiosqlite(self):
  19. """Verify aiosqlite is set to WARNING in debug mode to prevent cursor noise."""
  20. import logging
  21. from backend.app.api.routes.support import _apply_log_level
  22. _apply_log_level(True)
  23. assert logging.getLogger("aiosqlite").level == logging.WARNING
  24. def test_debug_mode_enables_httpcore_debug(self):
  25. """Verify httpcore stays at DEBUG in debug mode."""
  26. import logging
  27. from backend.app.api.routes.support import _apply_log_level
  28. _apply_log_level(True)
  29. assert logging.getLogger("httpcore").level == logging.DEBUG
  30. def test_non_debug_mode_suppresses_all_noisy_loggers(self):
  31. """Verify all noisy loggers are set to WARNING in non-debug mode."""
  32. import logging
  33. from backend.app.api.routes.support import _apply_log_level
  34. _apply_log_level(False)
  35. assert logging.getLogger("sqlalchemy.engine").level == logging.WARNING
  36. assert logging.getLogger("httpcore").level == logging.WARNING
  37. assert logging.getLogger("httpx").level == logging.WARNING
  38. assert logging.getLogger("paho.mqtt").level == logging.WARNING
  39. class TestAnonymizeMqttBroker:
  40. """Tests for _anonymize_mqtt_broker()."""
  41. def test_empty_string(self):
  42. from backend.app.api.routes.support import _anonymize_mqtt_broker
  43. assert _anonymize_mqtt_broker("") == ""
  44. def test_ipv4_address(self):
  45. from backend.app.api.routes.support import _anonymize_mqtt_broker
  46. assert _anonymize_mqtt_broker("192.168.1.100") == "[IP]"
  47. def test_ipv6_address(self):
  48. from backend.app.api.routes.support import _anonymize_mqtt_broker
  49. assert _anonymize_mqtt_broker("::1") == "[IP]"
  50. def test_hostname_with_domain(self):
  51. from backend.app.api.routes.support import _anonymize_mqtt_broker
  52. assert _anonymize_mqtt_broker("mqtt.example.com") == "*.example.com"
  53. def test_hostname_with_subdomain(self):
  54. from backend.app.api.routes.support import _anonymize_mqtt_broker
  55. assert _anonymize_mqtt_broker("broker.mqtt.example.com") == "*.example.com"
  56. def test_single_part_hostname(self):
  57. from backend.app.api.routes.support import _anonymize_mqtt_broker
  58. assert _anonymize_mqtt_broker("localhost") == "localhost"
  59. class TestCheckPort:
  60. """Tests for _check_port()."""
  61. @pytest.mark.asyncio
  62. @pytest.mark.unit
  63. async def test_reachable_port(self):
  64. from backend.app.api.routes.support import _check_port
  65. # Mock a successful connection
  66. mock_writer = AsyncMock()
  67. mock_writer.close = MagicMock()
  68. mock_writer.wait_closed = AsyncMock()
  69. with patch("backend.app.api.routes.support.asyncio.open_connection", return_value=(AsyncMock(), mock_writer)):
  70. result = await _check_port("192.168.1.1", 8883, timeout=1.0)
  71. assert result is True
  72. @pytest.mark.asyncio
  73. @pytest.mark.unit
  74. async def test_unreachable_port(self):
  75. from backend.app.api.routes.support import _check_port
  76. with (
  77. patch(
  78. "backend.app.api.routes.support.asyncio.open_connection",
  79. side_effect=ConnectionRefusedError,
  80. ),
  81. patch(
  82. "backend.app.api.routes.support.asyncio.wait_for",
  83. side_effect=ConnectionRefusedError,
  84. ),
  85. ):
  86. result = await _check_port("192.168.1.1", 8883, timeout=1.0)
  87. assert result is False
  88. @pytest.mark.asyncio
  89. @pytest.mark.unit
  90. async def test_timeout(self):
  91. from backend.app.api.routes.support import _check_port
  92. with patch(
  93. "backend.app.api.routes.support.asyncio.wait_for",
  94. side_effect=asyncio.TimeoutError,
  95. ):
  96. result = await _check_port("192.168.1.1", 8883, timeout=0.1)
  97. assert result is False
  98. class TestGetContainerMemoryLimit:
  99. """Tests for _get_container_memory_limit()."""
  100. def test_cgroup_v2_with_limit(self):
  101. from backend.app.api.routes.support import _get_container_memory_limit
  102. with tempfile.TemporaryDirectory() as tmpdir:
  103. v2_path = Path(tmpdir) / "memory.max"
  104. v2_path.write_text("1073741824\n")
  105. with patch("backend.app.api.routes.support.Path") as mock_path:
  106. # v2 path exists with value
  107. v2_mock = MagicMock()
  108. v2_mock.exists.return_value = True
  109. v2_mock.read_text.return_value = "1073741824\n"
  110. v1_mock = MagicMock()
  111. v1_mock.exists.return_value = False
  112. mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
  113. result = _get_container_memory_limit()
  114. assert result == 1073741824
  115. def test_cgroup_v2_unlimited(self):
  116. from backend.app.api.routes.support import _get_container_memory_limit
  117. with patch("backend.app.api.routes.support.Path") as mock_path:
  118. v2_mock = MagicMock()
  119. v2_mock.exists.return_value = True
  120. v2_mock.read_text.return_value = "max\n"
  121. v1_mock = MagicMock()
  122. v1_mock.exists.return_value = False
  123. mock_path.side_effect = lambda p: v2_mock if "memory.max" in p else v1_mock
  124. result = _get_container_memory_limit()
  125. assert result is None
  126. def test_no_cgroup_files(self):
  127. from backend.app.api.routes.support import _get_container_memory_limit
  128. with patch("backend.app.api.routes.support.Path") as mock_path:
  129. mock_instance = MagicMock()
  130. mock_instance.exists.return_value = False
  131. mock_path.return_value = mock_instance
  132. result = _get_container_memory_limit()
  133. assert result is None
  134. class TestFormatBytes:
  135. """Tests for _format_bytes()."""
  136. def test_bytes(self):
  137. from backend.app.api.routes.support import _format_bytes
  138. assert _format_bytes(500) == "500 B"
  139. def test_kilobytes(self):
  140. from backend.app.api.routes.support import _format_bytes
  141. assert _format_bytes(2048) == "2.0 KB"
  142. def test_megabytes(self):
  143. from backend.app.api.routes.support import _format_bytes
  144. assert _format_bytes(10 * 1024 * 1024) == "10.0 MB"
  145. def test_gigabytes(self):
  146. from backend.app.api.routes.support import _format_bytes
  147. assert _format_bytes(2 * 1024 * 1024 * 1024) == "2.00 GB"
  148. def test_zero(self):
  149. from backend.app.api.routes.support import _format_bytes
  150. assert _format_bytes(0) == "0 B"
  151. class TestCollectSupportInfo:
  152. """Tests for _collect_support_info() new diagnostic sections."""
  153. @pytest.mark.asyncio
  154. @pytest.mark.unit
  155. async def test_environment_has_timezone(self):
  156. """Verify environment section includes timezone."""
  157. from backend.app.api.routes.support import _collect_support_info
  158. with (
  159. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  160. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  161. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  162. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  163. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  164. patch.dict("os.environ", {"TZ": "America/New_York"}),
  165. ):
  166. mock_pm.get_all_statuses.return_value = {}
  167. mock_ws.active_connections = []
  168. mock_db = AsyncMock()
  169. mock_result = MagicMock()
  170. mock_result.scalar.return_value = 0
  171. mock_result.scalar_one_or_none.return_value = None
  172. mock_result.scalars.return_value.all.return_value = []
  173. mock_db.execute = AsyncMock(return_value=mock_result)
  174. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  175. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  176. info = await _collect_support_info()
  177. assert info["environment"]["timezone"] == "America/New_York"
  178. assert info["environment"]["docker"] is False
  179. @pytest.mark.asyncio
  180. @pytest.mark.unit
  181. async def test_docker_section_present_when_in_docker(self):
  182. """Verify docker section is added when running in Docker."""
  183. from backend.app.api.routes.support import _collect_support_info
  184. with (
  185. patch("backend.app.api.routes.support.is_running_in_docker", return_value=True),
  186. patch("backend.app.api.routes.support._get_container_memory_limit", return_value=1073741824),
  187. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  188. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  189. patch(
  190. "backend.app.api.routes.support.get_network_interfaces",
  191. return_value=[{"name": "eth0", "subnet": "172.17.0.0/16"}],
  192. ),
  193. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  194. ):
  195. mock_pm.get_all_statuses.return_value = {}
  196. mock_ws.active_connections = []
  197. mock_db = AsyncMock()
  198. mock_result = MagicMock()
  199. mock_result.scalar.return_value = 0
  200. mock_result.scalar_one_or_none.return_value = None
  201. mock_result.scalars.return_value.all.return_value = []
  202. mock_db.execute = AsyncMock(return_value=mock_result)
  203. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  204. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  205. info = await _collect_support_info()
  206. assert "docker" in info
  207. assert info["docker"]["container_memory_limit_bytes"] == 1073741824
  208. assert info["docker"]["container_memory_limit_formatted"] == "1.00 GB"
  209. assert info["docker"]["network_mode_hint"] == "bridge"
  210. @pytest.mark.asyncio
  211. @pytest.mark.unit
  212. async def test_docker_section_absent_when_not_docker(self):
  213. """Verify docker section is absent when not in Docker."""
  214. from backend.app.api.routes.support import _collect_support_info
  215. with (
  216. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  217. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  218. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  219. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  220. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  221. ):
  222. mock_pm.get_all_statuses.return_value = {}
  223. mock_ws.active_connections = []
  224. mock_db = AsyncMock()
  225. mock_result = MagicMock()
  226. mock_result.scalar.return_value = 0
  227. mock_result.scalar_one_or_none.return_value = None
  228. mock_result.scalars.return_value.all.return_value = []
  229. mock_db.execute = AsyncMock(return_value=mock_result)
  230. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  231. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  232. info = await _collect_support_info()
  233. assert "docker" not in info
  234. @pytest.mark.asyncio
  235. @pytest.mark.unit
  236. async def test_dependencies_section(self):
  237. """Verify dependencies section lists package versions."""
  238. from backend.app.api.routes.support import _collect_support_info
  239. with (
  240. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  241. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  242. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  243. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  244. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  245. ):
  246. mock_pm.get_all_statuses.return_value = {}
  247. mock_ws.active_connections = []
  248. mock_db = AsyncMock()
  249. mock_result = MagicMock()
  250. mock_result.scalar.return_value = 0
  251. mock_result.scalar_one_or_none.return_value = None
  252. mock_result.scalars.return_value.all.return_value = []
  253. mock_db.execute = AsyncMock(return_value=mock_result)
  254. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  255. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  256. info = await _collect_support_info()
  257. assert "dependencies" in info
  258. # fastapi should be installed in test environment
  259. assert "fastapi" in info["dependencies"]
  260. assert info["dependencies"]["fastapi"] is not None
  261. @pytest.mark.asyncio
  262. @pytest.mark.unit
  263. async def test_websockets_section(self):
  264. """Verify websockets section shows connection count."""
  265. from backend.app.api.routes.support import _collect_support_info
  266. with (
  267. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  268. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  269. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  270. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  271. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  272. ):
  273. mock_pm.get_all_statuses.return_value = {}
  274. mock_ws.active_connections = ["conn1", "conn2"]
  275. mock_db = AsyncMock()
  276. mock_result = MagicMock()
  277. mock_result.scalar.return_value = 0
  278. mock_result.scalar_one_or_none.return_value = None
  279. mock_result.scalars.return_value.all.return_value = []
  280. mock_db.execute = AsyncMock(return_value=mock_result)
  281. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  282. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  283. info = await _collect_support_info()
  284. assert info["websockets"]["active_connections"] == 2
  285. @pytest.mark.asyncio
  286. @pytest.mark.unit
  287. async def test_network_section(self):
  288. """Verify network section shows interface subnets."""
  289. from backend.app.api.routes.support import _collect_support_info
  290. mock_interfaces = [
  291. {"name": "eth0", "ip": "192.168.1.100", "netmask": "255.255.255.0", "subnet": "192.168.1.0/24"},
  292. {"name": "wlan0", "ip": "10.0.0.50", "netmask": "255.255.255.0", "subnet": "10.0.0.0/24"},
  293. ]
  294. with (
  295. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  296. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  297. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  298. patch("backend.app.api.routes.support.get_network_interfaces", return_value=mock_interfaces),
  299. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  300. ):
  301. mock_pm.get_all_statuses.return_value = {}
  302. mock_ws.active_connections = []
  303. mock_db = AsyncMock()
  304. mock_result = MagicMock()
  305. mock_result.scalar.return_value = 0
  306. mock_result.scalar_one_or_none.return_value = None
  307. mock_result.scalars.return_value.all.return_value = []
  308. mock_db.execute = AsyncMock(return_value=mock_result)
  309. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  310. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  311. info = await _collect_support_info()
  312. assert info["network"]["interface_count"] == 2
  313. assert info["network"]["interfaces"][0]["name"] == "eth0"
  314. assert info["network"]["interfaces"][0]["subnet"] == "192.168.1.0/24"
  315. # Verify IP addresses are NOT included
  316. for iface in info["network"]["interfaces"]:
  317. assert "ip" not in iface
  318. @pytest.mark.asyncio
  319. @pytest.mark.unit
  320. async def test_log_file_section(self):
  321. """Verify log file section shows size info."""
  322. from backend.app.api.routes.support import _collect_support_info
  323. with tempfile.TemporaryDirectory() as tmpdir:
  324. log_dir = Path(tmpdir)
  325. log_file = log_dir / "bambuddy.log"
  326. log_file.write_text("some log content\n" * 100)
  327. with (
  328. patch("backend.app.api.routes.support.is_running_in_docker", return_value=False),
  329. patch("backend.app.api.routes.support.async_session") as mock_session_ctx,
  330. patch("backend.app.api.routes.support.printer_manager") as mock_pm,
  331. patch("backend.app.api.routes.support.get_network_interfaces", return_value=[]),
  332. patch("backend.app.api.routes.support.ws_manager") as mock_ws,
  333. patch("backend.app.api.routes.support.settings") as mock_settings,
  334. ):
  335. mock_settings.base_dir = Path(tmpdir)
  336. mock_settings.log_dir = log_dir
  337. mock_settings.debug = False
  338. mock_pm.get_all_statuses.return_value = {}
  339. mock_ws.active_connections = []
  340. mock_db = AsyncMock()
  341. mock_result = MagicMock()
  342. mock_result.scalar.return_value = 0
  343. mock_result.scalar_one_or_none.return_value = None
  344. mock_result.scalars.return_value.all.return_value = []
  345. mock_db.execute = AsyncMock(return_value=mock_result)
  346. mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
  347. mock_session_ctx.return_value.__aexit__ = AsyncMock(return_value=False)
  348. info = await _collect_support_info()
  349. assert "log_file" in info
  350. assert info["log_file"]["size_bytes"] > 0
  351. assert "B" in info["log_file"]["size_formatted"] or "KB" in info["log_file"]["size_formatted"]