test_support_helpers.py 17 KB

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