test_system_api.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_boot_time_isoformat_carries_utc_marker(self, async_client: AsyncClient):
  291. """#1690 follow-up: the boot_time string must include a UTC tz marker.
  292. Without it the frontend's parseUTCDate(...) appends 'Z' to a naive-
  293. local-time string, treats it as UTC, and converts to local — applying
  294. the local offset twice. The reporter (UTC+3) saw boot_time +3h ahead
  295. even though uptime was correct (uptime is computed backend-side from
  296. two naive-local values whose delta is right). The fix is to make both
  297. ends tz-aware UTC and emit an explicit offset.
  298. """
  299. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  300. mock_psutil.disk_usage.return_value = MagicMock(
  301. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  302. )
  303. mock_psutil.virtual_memory.return_value = MagicMock(
  304. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  305. )
  306. mock_psutil.boot_time.return_value = 1700000000.0
  307. mock_psutil.Process.return_value.create_time.return_value = 1700345600.0
  308. mock_psutil.cpu_count.return_value = 4
  309. mock_psutil.cpu_percent.return_value = 25.0
  310. response = await async_client.get("/api/v1/system/info")
  311. assert response.status_code == 200
  312. boot_time = response.json()["system"]["boot_time"]
  313. assert boot_time.endswith("+00:00") or boot_time.endswith("Z"), (
  314. f"boot_time {boot_time!r} must carry a UTC tz marker; without one the "
  315. "frontend double-converts via parseUTCDate"
  316. )
  317. class TestSystemHelperFunctions:
  318. """Tests for system info helper functions."""
  319. def test_format_bytes_bytes(self):
  320. """Verify format_bytes handles bytes correctly."""
  321. from backend.app.api.routes.system import format_bytes
  322. assert format_bytes(500) == "500.0 B"
  323. def test_format_bytes_kilobytes(self):
  324. """Verify format_bytes handles kilobytes correctly."""
  325. from backend.app.api.routes.system import format_bytes
  326. result = format_bytes(1536)
  327. assert "KB" in result
  328. def test_format_bytes_megabytes(self):
  329. """Verify format_bytes handles megabytes correctly."""
  330. from backend.app.api.routes.system import format_bytes
  331. result = format_bytes(1536 * 1024)
  332. assert "MB" in result
  333. def test_format_bytes_gigabytes(self):
  334. """Verify format_bytes handles gigabytes correctly."""
  335. from backend.app.api.routes.system import format_bytes
  336. result = format_bytes(1536 * 1024 * 1024)
  337. assert "GB" in result
  338. def test_format_uptime_minutes(self):
  339. """Verify format_uptime handles minutes correctly."""
  340. from backend.app.api.routes.system import format_uptime
  341. result = format_uptime(300) # 5 minutes
  342. assert "5m" in result
  343. def test_format_uptime_hours(self):
  344. """Verify format_uptime handles hours correctly."""
  345. from backend.app.api.routes.system import format_uptime
  346. result = format_uptime(7200) # 2 hours
  347. assert "2h" in result
  348. def test_format_uptime_days(self):
  349. """Verify format_uptime handles days correctly."""
  350. from backend.app.api.routes.system import format_uptime
  351. result = format_uptime(86400 * 2 + 3600 * 5) # 2 days 5 hours
  352. assert "2d" in result
  353. assert "5h" in result
  354. def test_format_uptime_less_than_minute(self):
  355. """Verify format_uptime handles < 1 minute correctly."""
  356. from backend.app.api.routes.system import format_uptime
  357. result = format_uptime(30) # 30 seconds
  358. assert result == "< 1m"
  359. class TestSystemHealthAPI:
  360. """Integration tests for GET /api/v1/system/health (log-health scan)."""
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_health_clean_log(self, async_client: AsyncClient, tmp_path, monkeypatch):
  364. """A log with no known issues returns an empty, healthy result."""
  365. from backend.app.core.config import settings
  366. (tmp_path / "bambuddy.log").write_text(
  367. "2026-05-22 10:00:00,000 INFO [backend.app.main] Application startup complete\n",
  368. encoding="utf-8",
  369. )
  370. monkeypatch.setattr(settings, "log_dir", tmp_path)
  371. response = await async_client.get("/api/v1/system/health")
  372. assert response.status_code == 200
  373. result = response.json()
  374. assert result["log_available"] is True
  375. assert result["findings"] == []
  376. assert result["summary"]["total"] == 0
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_health_detects_known_issue(self, async_client: AsyncClient, tmp_path, monkeypatch):
  380. """A known signature in the log surfaces as a finding."""
  381. from backend.app.core.config import settings
  382. (tmp_path / "bambuddy.log").write_text(
  383. "2026-05-22 10:00:00,000 WARNING [backend.app.services.bambu_ftp] "
  384. "FTP connection permission error to 10.0.0.9: 530\n",
  385. encoding="utf-8",
  386. )
  387. monkeypatch.setattr(settings, "log_dir", tmp_path)
  388. response = await async_client.get("/api/v1/system/health")
  389. assert response.status_code == 200
  390. result = response.json()
  391. ids = [f["signature_id"] for f in result["findings"]]
  392. assert "ftp-auth-rejected" in ids
  393. assert result["summary"]["layer8"] >= 1