test_system_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  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.cpu_count.return_value = 4
  26. mock_psutil.cpu_percent.return_value = 25.0
  27. response = await async_client.get("/api/v1/system/info")
  28. assert response.status_code == 200
  29. result = response.json()
  30. # Verify top-level structure
  31. assert "app" in result
  32. assert "database" in result
  33. assert "printers" in result
  34. assert "storage" in result
  35. assert "system" in result
  36. assert "memory" in result
  37. assert "cpu" in result
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_system_info_app_section(self, async_client: AsyncClient):
  41. """Verify app section contains version and directory info."""
  42. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  43. mock_psutil.disk_usage.return_value = MagicMock(
  44. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  45. )
  46. mock_psutil.virtual_memory.return_value = MagicMock(
  47. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  48. )
  49. mock_psutil.boot_time.return_value = 1700000000.0
  50. mock_psutil.cpu_count.return_value = 4
  51. mock_psutil.cpu_percent.return_value = 25.0
  52. response = await async_client.get("/api/v1/system/info")
  53. result = response.json()
  54. app_info = result["app"]
  55. assert "version" in app_info
  56. assert "base_dir" in app_info
  57. assert "archive_dir" in app_info
  58. @pytest.mark.asyncio
  59. @pytest.mark.integration
  60. async def test_system_info_database_section(self, async_client: AsyncClient):
  61. """Verify database section contains counts and statistics."""
  62. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  63. mock_psutil.disk_usage.return_value = MagicMock(
  64. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  65. )
  66. mock_psutil.virtual_memory.return_value = MagicMock(
  67. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  68. )
  69. mock_psutil.boot_time.return_value = 1700000000.0
  70. mock_psutil.cpu_count.return_value = 4
  71. mock_psutil.cpu_percent.return_value = 25.0
  72. response = await async_client.get("/api/v1/system/info")
  73. result = response.json()
  74. db_info = result["database"]
  75. assert "archives" in db_info
  76. assert "archives_completed" in db_info
  77. assert "archives_failed" in db_info
  78. assert "printers" in db_info
  79. assert "filaments" in db_info
  80. assert "projects" in db_info
  81. assert "smart_plugs" in db_info
  82. assert "total_print_time_seconds" in db_info
  83. assert "total_print_time_formatted" in db_info
  84. assert "total_filament_grams" in db_info
  85. assert "total_filament_kg" in db_info
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_system_info_storage_section(self, async_client: AsyncClient):
  89. """Verify storage section contains disk usage info."""
  90. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  91. mock_psutil.disk_usage.return_value = MagicMock(
  92. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  93. )
  94. mock_psutil.virtual_memory.return_value = MagicMock(
  95. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  96. )
  97. mock_psutil.boot_time.return_value = 1700000000.0
  98. mock_psutil.cpu_count.return_value = 4
  99. mock_psutil.cpu_percent.return_value = 25.0
  100. response = await async_client.get("/api/v1/system/info")
  101. result = response.json()
  102. storage_info = result["storage"]
  103. assert "archive_size_bytes" in storage_info
  104. assert "archive_size_formatted" in storage_info
  105. assert "database_size_bytes" in storage_info
  106. assert "database_size_formatted" in storage_info
  107. assert "disk_total_bytes" in storage_info
  108. assert "disk_total_formatted" in storage_info
  109. assert "disk_used_bytes" in storage_info
  110. assert "disk_free_bytes" in storage_info
  111. assert "disk_percent_used" in storage_info
  112. @pytest.mark.asyncio
  113. @pytest.mark.integration
  114. async def test_system_info_memory_section(self, async_client: AsyncClient):
  115. """Verify memory section contains RAM usage info."""
  116. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  117. mock_psutil.disk_usage.return_value = MagicMock(
  118. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  119. )
  120. mock_psutil.virtual_memory.return_value = MagicMock(
  121. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  122. )
  123. mock_psutil.boot_time.return_value = 1700000000.0
  124. mock_psutil.cpu_count.return_value = 4
  125. mock_psutil.cpu_percent.return_value = 25.0
  126. response = await async_client.get("/api/v1/system/info")
  127. result = response.json()
  128. memory_info = result["memory"]
  129. assert "total_bytes" in memory_info
  130. assert "total_formatted" in memory_info
  131. assert "available_bytes" in memory_info
  132. assert "used_bytes" in memory_info
  133. assert "percent_used" in memory_info
  134. @pytest.mark.asyncio
  135. @pytest.mark.integration
  136. async def test_system_info_cpu_section(self, async_client: AsyncClient):
  137. """Verify CPU section contains processor info."""
  138. with patch("backend.app.api.routes.system.psutil") as mock_psutil:
  139. mock_psutil.disk_usage.return_value = MagicMock(
  140. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  141. )
  142. mock_psutil.virtual_memory.return_value = MagicMock(
  143. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  144. )
  145. mock_psutil.boot_time.return_value = 1700000000.0
  146. mock_psutil.cpu_count.return_value = 4
  147. mock_psutil.cpu_percent.return_value = 25.0
  148. response = await async_client.get("/api/v1/system/info")
  149. result = response.json()
  150. cpu_info = result["cpu"]
  151. assert "count" in cpu_info
  152. assert "count_logical" in cpu_info
  153. assert "percent" in cpu_info
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_system_info_printers_section(self, async_client: AsyncClient, printer_factory):
  157. """Verify printers section contains connected printer info."""
  158. # Create a test printer
  159. _printer = await printer_factory(name="Test Printer", model="X1C")
  160. with (
  161. patch("backend.app.api.routes.system.psutil") as mock_psutil,
  162. patch("backend.app.api.routes.system.printer_manager") as mock_pm,
  163. ):
  164. mock_psutil.disk_usage.return_value = MagicMock(
  165. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  166. )
  167. mock_psutil.virtual_memory.return_value = MagicMock(
  168. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  169. )
  170. mock_psutil.boot_time.return_value = 1700000000.0
  171. mock_psutil.cpu_count.return_value = 4
  172. mock_psutil.cpu_percent.return_value = 25.0
  173. # Mock no connected printers for simplicity
  174. mock_pm._clients = {}
  175. response = await async_client.get("/api/v1/system/info")
  176. result = response.json()
  177. printers_info = result["printers"]
  178. assert "total" in printers_info
  179. assert "connected" in printers_info
  180. assert "connected_list" in printers_info
  181. assert printers_info["total"] >= 1 # At least our test printer
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_system_info_with_archives(self, async_client: AsyncClient, printer_factory, archive_factory):
  185. """Verify database stats include archive counts.
  186. Post-#1593 `total_print_time_seconds` is summed from
  187. `PrintLogEntry.duration_seconds` (the *actual* per-run duration),
  188. not `PrintArchive.print_time_seconds` (the slicer estimate). The
  189. archive_factory derives the run's duration from
  190. ``completed_at - started_at`` on the archive, so the test sets
  191. those so each run carries a duration the system route can sum.
  192. """
  193. from datetime import datetime, timezone
  194. printer = await printer_factory()
  195. await archive_factory(
  196. printer.id,
  197. status="completed",
  198. print_time_seconds=3600,
  199. started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
  200. completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  201. )
  202. await archive_factory(
  203. printer.id,
  204. status="failed",
  205. print_time_seconds=1800,
  206. started_at=datetime(2026, 5, 2, 10, 0, tzinfo=timezone.utc),
  207. completed_at=datetime(2026, 5, 2, 10, 30, tzinfo=timezone.utc),
  208. )
  209. with (
  210. patch("backend.app.api.routes.system.psutil") as mock_psutil,
  211. patch("backend.app.api.routes.system.printer_manager") as mock_pm,
  212. ):
  213. mock_psutil.disk_usage.return_value = MagicMock(
  214. total=500000000000, used=250000000000, free=250000000000, percent=50.0
  215. )
  216. mock_psutil.virtual_memory.return_value = MagicMock(
  217. total=16000000000, available=8000000000, used=8000000000, percent=50.0
  218. )
  219. mock_psutil.boot_time.return_value = 1700000000.0
  220. mock_psutil.cpu_count.return_value = 4
  221. mock_psutil.cpu_percent.return_value = 25.0
  222. mock_pm._clients = {}
  223. response = await async_client.get("/api/v1/system/info")
  224. result = response.json()
  225. db_info = result["database"]
  226. assert db_info["archives"] >= 2
  227. assert db_info["archives_completed"] >= 1
  228. assert db_info["archives_failed"] >= 1
  229. assert db_info["total_print_time_seconds"] >= 5400
  230. class TestSystemHelperFunctions:
  231. """Tests for system info helper functions."""
  232. def test_format_bytes_bytes(self):
  233. """Verify format_bytes handles bytes correctly."""
  234. from backend.app.api.routes.system import format_bytes
  235. assert format_bytes(500) == "500.0 B"
  236. def test_format_bytes_kilobytes(self):
  237. """Verify format_bytes handles kilobytes correctly."""
  238. from backend.app.api.routes.system import format_bytes
  239. result = format_bytes(1536)
  240. assert "KB" in result
  241. def test_format_bytes_megabytes(self):
  242. """Verify format_bytes handles megabytes correctly."""
  243. from backend.app.api.routes.system import format_bytes
  244. result = format_bytes(1536 * 1024)
  245. assert "MB" in result
  246. def test_format_bytes_gigabytes(self):
  247. """Verify format_bytes handles gigabytes correctly."""
  248. from backend.app.api.routes.system import format_bytes
  249. result = format_bytes(1536 * 1024 * 1024)
  250. assert "GB" in result
  251. def test_format_uptime_minutes(self):
  252. """Verify format_uptime handles minutes correctly."""
  253. from backend.app.api.routes.system import format_uptime
  254. result = format_uptime(300) # 5 minutes
  255. assert "5m" in result
  256. def test_format_uptime_hours(self):
  257. """Verify format_uptime handles hours correctly."""
  258. from backend.app.api.routes.system import format_uptime
  259. result = format_uptime(7200) # 2 hours
  260. assert "2h" in result
  261. def test_format_uptime_days(self):
  262. """Verify format_uptime handles days correctly."""
  263. from backend.app.api.routes.system import format_uptime
  264. result = format_uptime(86400 * 2 + 3600 * 5) # 2 days 5 hours
  265. assert "2d" in result
  266. assert "5h" in result
  267. def test_format_uptime_less_than_minute(self):
  268. """Verify format_uptime handles < 1 minute correctly."""
  269. from backend.app.api.routes.system import format_uptime
  270. result = format_uptime(30) # 30 seconds
  271. assert result == "< 1m"
  272. class TestSystemHealthAPI:
  273. """Integration tests for GET /api/v1/system/health (log-health scan)."""
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_health_clean_log(self, async_client: AsyncClient, tmp_path, monkeypatch):
  277. """A log with no known issues returns an empty, healthy result."""
  278. from backend.app.core.config import settings
  279. (tmp_path / "bambuddy.log").write_text(
  280. "2026-05-22 10:00:00,000 INFO [backend.app.main] Application startup complete\n",
  281. encoding="utf-8",
  282. )
  283. monkeypatch.setattr(settings, "log_dir", tmp_path)
  284. response = await async_client.get("/api/v1/system/health")
  285. assert response.status_code == 200
  286. result = response.json()
  287. assert result["log_available"] is True
  288. assert result["findings"] == []
  289. assert result["summary"]["total"] == 0
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_health_detects_known_issue(self, async_client: AsyncClient, tmp_path, monkeypatch):
  293. """A known signature in the log surfaces as a finding."""
  294. from backend.app.core.config import settings
  295. (tmp_path / "bambuddy.log").write_text(
  296. "2026-05-22 10:00:00,000 WARNING [backend.app.services.bambu_ftp] "
  297. "FTP connection permission error to 10.0.0.9: 530\n",
  298. encoding="utf-8",
  299. )
  300. monkeypatch.setattr(settings, "log_dir", tmp_path)
  301. response = await async_client.get("/api/v1/system/health")
  302. assert response.status_code == 200
  303. result = response.json()
  304. ids = [f["signature_id"] for f in result["findings"]]
  305. assert "ftp-auth-rejected" in ids
  306. assert result["summary"]["layer8"] >= 1