test_system_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. """Integration tests for System API endpoints.
  2. Tests the full request/response cycle for /api/v1/system/ endpoints.
  3. """
  4. from unittest.mock import MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestSystemAPI:
  8. """Integration tests for /api/v1/system/ endpoints."""
  9. # ========================================================================
  10. # System Info Endpoint
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_get_system_info(self, async_client: AsyncClient):
  15. """Verify system info endpoint returns expected structure."""
  16. # Mock psutil to avoid system-specific values
  17. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  18. mock_psutil.disk_usage.return_value = MagicMock(
  19. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  20. )
  21. mock_psutil.virtual_memory.return_value = MagicMock(
  22. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  23. )
  24. mock_psutil.boot_time.return_value = 1700000000.0
  25. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  26. mock_psutil.cpu_count.return_value = 4
  27. mock_psutil.cpu_percent.return_value = 25.0
  28. response = await async_client.get("/api/v1/system/info")
  29. assert response.status_code == 200
  30. result = response.json()
  31. # Verify top-level structure
  32. assert "app" in result
  33. assert "database" in result
  34. assert "printers" in result
  35. assert "storage" in result
  36. assert "system" in result
  37. assert "memory" in result
  38. assert "cpu" in result
  39. @pytest.mark.asyncio
  40. @pytest.mark.integration
  41. async def test_system_info_app_section(self, async_client: AsyncClient):
  42. """Verify app section contains version and directory info."""
  43. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  44. mock_psutil.disk_usage.return_value = MagicMock(
  45. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  46. )
  47. mock_psutil.virtual_memory.return_value = MagicMock(
  48. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  49. )
  50. mock_psutil.boot_time.return_value = 1700000000.0
  51. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  52. mock_psutil.cpu_count.return_value = 4
  53. mock_psutil.cpu_percent.return_value = 25.0
  54. response = await async_client.get("/api/v1/system/info")
  55. result = response.json()
  56. app_info = result["app"]
  57. assert "version" in app_info
  58. assert "base_dir" in app_info
  59. assert "archive_dir" in app_info
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_system_info_database_section(self, async_client: AsyncClient):
  63. """Verify database section contains counts and statistics."""
  64. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  65. mock_psutil.disk_usage.return_value = MagicMock(
  66. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  67. )
  68. mock_psutil.virtual_memory.return_value = MagicMock(
  69. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  70. )
  71. mock_psutil.boot_time.return_value = 1700000000.0
  72. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  73. mock_psutil.cpu_count.return_value = 4
  74. mock_psutil.cpu_percent.return_value = 25.0
  75. response = await async_client.get("/api/v1/system/info")
  76. result = response.json()
  77. db_info = result["database"]
  78. assert "archives" in db_info
  79. assert "archives_completed" in db_info
  80. assert "archives_failed" in db_info
  81. assert "printers" in db_info
  82. assert "filaments" in db_info
  83. assert "projects" in db_info
  84. assert "smart_plugs" in db_info
  85. assert "total_print_time_seconds" in db_info
  86. assert "total_print_time_formatted" in db_info
  87. assert "total_filament_grams" in db_info
  88. assert "total_filament_kg" in db_info
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_system_info_storage_section(self, async_client: AsyncClient):
  92. """Verify storage section contains disk usage info."""
  93. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  94. mock_psutil.disk_usage.return_value = MagicMock(
  95. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  96. )
  97. mock_psutil.virtual_memory.return_value = MagicMock(
  98. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  99. )
  100. mock_psutil.boot_time.return_value = 1700000000.0
  101. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  102. mock_psutil.cpu_count.return_value = 4
  103. mock_psutil.cpu_percent.return_value = 25.0
  104. response = await async_client.get("/api/v1/system/info")
  105. result = response.json()
  106. storage_info = result["storage"]
  107. assert "archive_size_bytes" in storage_info
  108. assert "archive_size_formatted" in storage_info
  109. assert "database_size_bytes" in storage_info
  110. assert "database_size_formatted" in storage_info
  111. assert "disk_total_bytes" in storage_info
  112. assert "disk_total_formatted" in storage_info
  113. assert "disk_used_bytes" in storage_info
  114. assert "disk_free_bytes" in storage_info
  115. assert "disk_percent_used" in storage_info
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_system_info_memory_section(self, async_client: AsyncClient):
  119. """Verify memory section contains RAM usage info."""
  120. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  121. mock_psutil.disk_usage.return_value = MagicMock(
  122. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  123. )
  124. mock_psutil.virtual_memory.return_value = MagicMock(
  125. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  126. )
  127. mock_psutil.boot_time.return_value = 1700000000.0
  128. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  129. mock_psutil.cpu_count.return_value = 4
  130. mock_psutil.cpu_percent.return_value = 25.0
  131. response = await async_client.get("/api/v1/system/info")
  132. result = response.json()
  133. memory_info = result["memory"]
  134. assert "total_bytes" in memory_info
  135. assert "total_formatted" in memory_info
  136. assert "available_bytes" in memory_info
  137. assert "used_bytes" in memory_info
  138. assert "percent_used" in memory_info
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_system_info_cpu_section(self, async_client: AsyncClient):
  142. """Verify CPU section contains processor info."""
  143. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  144. mock_psutil.disk_usage.return_value = MagicMock(
  145. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  146. )
  147. mock_psutil.virtual_memory.return_value = MagicMock(
  148. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  149. )
  150. mock_psutil.boot_time.return_value = 1700000000.0
  151. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  152. mock_psutil.cpu_count.return_value = 4
  153. mock_psutil.cpu_percent.return_value = 25.0
  154. response = await async_client.get("/api/v1/system/info")
  155. result = response.json()
  156. cpu_info = result["cpu"]
  157. assert "count" in cpu_info
  158. assert "count_logical" in cpu_info
  159. assert "percent" in cpu_info
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):
  163. """Verify printers section contains connected printer info."""
  164. # Create a test printer
  165. _printer = await printer_factory(name="Test Printer", model="X1C")
  166. with (
  167. patch("backend.app.api.routes.system.psutil") as mock_psutil,
  168. patch("backend.app.api.routes.system.printer_manager") as mock_pm,
  169. ):
  170. mock_psutil.disk_usage.return_value = MagicMock(
  171. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  172. )
  173. mock_psutil.virtual_memory.return_value = MagicMock(
  174. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  175. )
  176. mock_psutil.boot_time.return_value = 1700000000.0
  177. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  178. mock_psutil.cpu_count.return_value = 4
  179. mock_psutil.cpu_percent.return_value = 25.0
  180. # Mock no connected printers for simplicity
  181. mock_pm._clients = {}
  182. response = await async_client.get("/api/v1/system/info")
  183. result = response.json()
  184. printers_info = result["printers"]
  185. assert "total" in printers_info
  186. assert "connected" in printers_info
  187. assert "connected_list" in printers_info
  188. assert printers_info["total"] >= 1 # At least our test printer
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
  192. """Verify database stats include archive counts.
  193. Post-#1593 `total_print_time_seconds` is summed from
  194. `PrintLogEntry.duration_seconds` (the *actual* per-run duration),
  195. not `PrintArchive.print_time_seconds` (the slicer estimate). The
  196. archive_factory derives the run's duration from
  197. ``completed_at - started_at`` on the archive, so the test sets
  198. those so each run carries a duration the system route can sum.
  199. """
  200. from datetime import datetime, timezone
  201. printer = await printer_factory()
  202. await archive_factory(
  203. printer.id,
  204. status="completed",
  205. print_time_seconds=3600,
  206. started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
  207. completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  208. )
  209. await archive_factory(
  210. printer.id,
  211. status="failed",
  212. print_time_seconds=1800,
  213. started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc),
  214. completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc),
  215. )
  216. with (
  217. patch("backend.app.api.routes.system.psutil") as mock_psutil,
  218. patch("backend.app.api.routes.system.printer_manager") as mock_pm,
  219. ):
  220. mock_psutil.disk_usage.return_value = MagicMock(
  221. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  222. )
  223. mock_psutil.virtual_memory.return_value = MagicMock(
  224. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  225. )
  226. mock_psutil.boot_time.return_value = 1700000000.0
  227. mock_psutil.Process.return_value.create_time.return_value = 1700000000.0
  228. mock_psutil.cpu_count.return_value = 4
  229. mock_psutil.cpu_percent.return_value = 25.0
  230. mock_pm._clients = {}
  231. response = await async_client.get("/api/v1/system/info")
  232. result = response.json()
  233. db_info = result["database"]
  234. assert db_info["archives"] >= 2
  235. assert db_info["archives_completed"] >= 1
  236. assert db_info["archives_failed"] >= 1
  237. assert db_info["total_print_time_seconds"] >= 5400
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_boot_time_uses_pid1_create_time(self, async_client: AsyncClient):
  241. """#1690: container installs (Docker/LXC) share the host kernel, so
  242. psutil.boot_time() returns the host's boot time instead of the
  243. container's. Reading PID 1's create_time gives the container start
  244. time on containers and matches host boot on bare metal."""
  245. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  246. mock_psutil.disk_usage.return_value = MagicMock(
  247. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  248. )
  249. mock_psutil.virtual_memory.return_value = MagicMock(
  250. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  251. )
  252. # Host boot is FOUR DAYS earlier than the container's PID 1 start.
  253. # The route must report the PID 1 value, not the host value.
  254. mock_psutil.boot_time.return_value = 1700000000.0
  255. mock_psutil.Process.return_value.create_time.return_value = 1700345600.0
  256. mock_psutil.cpu_count.return_value = 4
  257. mock_psutil.cpu_percent.return_value = 25.0
  258. response = await async_client.get("/api/v1/system/info")
  259. assert response.status_code == 200
  260. result = response.json()
  261. assert result["system"]["boot_time"].startswith("2023-11-18T") # 1700345600 UTC
  262. # PID 1 was queried with pid=1 (not the worker pid).
  263. mock_psutil.Process.assert_called_with(1)
  264. @pytest.mark.asyncio
  265. @pytest.mark.integration
  266. async def test_boot_time_falls_back_to_psutil_boot_time_on_pid1_failure(self, async_client: AsyncClient):
  267. """If PID 1 is unreadable (rare — locked-down container, /proc not
  268. mounted), fall back to psutil.boot_time() so the endpoint still
  269. returns 200 with the best available answer."""
  270. import psutil as real_psutil
  271. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  272. mock_psutil.disk_usage.return_value = MagicMock(
  273. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  274. )
  275. mock_psutil.virtual_memory.return_value = MagicMock(
  276. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  277. )
  278. mock_psutil.boot_time.return_value = 1700000000.0
  279. # Use the real exception classes so the route's except clause matches.
  280. mock_psutil.Error = real_psutil.Error
  281. mock_psutil.Process.side_effect = real_psutil.NoSuchProcess(1)
  282. mock_psutil.cpu_count.return_value = 4
  283. mock_psutil.cpu_percent.return_value = 25.0
  284. response = await async_client.get("/api/v1/system/info")
  285. assert response.status_code == 200
  286. result = response.json()
  287. assert result["system"]["boot_time"].startswith("2023-11-14T") # 1700000000 UTC
  288. class TestSystemHelperFunctions:
  289. """Tests for system info helper functions."""
  290. def test_format_bytes_bytes(self):
  291. """Verify format_bytes handles bytes correctly."""
  292. from backend.app.api.routes.system import format_bytes
  293. assert format_bytes(500) == "500.0 B"
  294. def test_format_bytes_kilobytes(self):
  295. """Verify format_bytes handles kilobytes correctly."""
  296. from backend.app.api.routes.system import format_bytes
  297. result = format_bytes(1536)
  298. assert "KB" in result
  299. def test_format_bytes_megabytes(self):
  300. """Verify format_bytes handles megabytes correctly."""
  301. from backend.app.api.routes.system import format_bytes
  302. result = format_bytes(1536 * 1024)
  303. assert "MB" in result
  304. def test_format_bytes_gigabytes(self):
  305. """Verify format_bytes handles gigabytes correctly."""
  306. from backend.app.api.routes.system import format_bytes
  307. result = format_bytes(1536 * 1024 * 1024)
  308. assert "GB" in result
  309. def test_format_uptime_minutes(self):
  310. """Verify format_uptime handles minutes correctly."""
  311. from backend.app.api.routes.system import format_uptime
  312. result = format_uptime(300) # 5 minutes
  313. assert "5m" in result
  314. def test_format_uptime_hours(self):
  315. """Verify format_uptime handles hours correctly."""
  316. from backend.app.api.routes.system import format_uptime
  317. result = format_uptime(7200) # 2 hours
  318. assert "2h" in result
  319. def test_format_uptime_days(self):
  320. """Verify format_uptime handles days correctly."""
  321. from backend.app.api.routes.system import format_uptime
  322. result = format_uptime(86400 * 2 + 3600 * 5) # 2 days 5 hours
  323. assert "2d" in result
  324. assert "5h" in result
  325. def test_format_uptime_less_than_minute(self):
  326. """Verify format_uptime handles < 1 minute correctly."""
  327. from backend.app.api.routes.system import format_uptime
  328. result = format_uptime(30) # 30 seconds
  329. assert result == "< 1m"
  330. class TestSystemHealthAPI:
  331. """Integration tests for GET /api/v1/system/health (log-health scan)."""
  332. @pytest.mark.asyncio
  333. @pytest.mark.integration
  334. async def test_health_clean_log(self, async_client: AsyncClient, tmp_path, monkeypatch):
  335. """A log with no known issues returns an empty, healthy result."""
  336. from backend.app.core.config import settings
  337. (tmp_path / "bambuddy.log").write_text(
  338. "2026-05-22 10:00:00,000 INFO [backend.app.main] Application startup complete\n",
  339. encoding="utf-8",
  340. )
  341. monkeypatch.setattr(settings, "log_dir", tmp_path)
  342. response = await async_client.get("/api/v1/system/health")
  343. assert response.status_code == 200
  344. result = response.json()
  345. assert result["log_available"] is True
  346. assert result["findings"] == []
  347. assert result["summary"]["total"] == 0
  348. @pytest.mark.asyncio
  349. @pytest.mark.integration
  350. async def test_health_detects_known_issue(self, async_client: AsyncClient, tmp_path, monkeypatch):
  351. """A known signature in the log surfaces as a finding."""
  352. from backend.app.core.config import settings
  353. (tmp_path / "bambuddy.log").write_text(
  354. "2026-05-22 10:00:00,000 WARNING [backend.app.services.bambu_ftp] "
  355. "FTP connection permission error to 10.0.0.9: 530\n",
  356. encoding="utf-8",
  357. )
  358. monkeypatch.setattr(settings, "log_dir", tmp_path)
  359. response = await async_client.get("/api/v1/system/health")
  360. assert response.status_code == 200
  361. result = response.json()
  362. ids = [f["signature_id"] for f in result["findings"]]
  363. assert "ftp-auth-rejected" in ids
  364. assert result["summary"]["layer8"] >= 1